Site Logo

Pixel-in-Gene

Exploring Frontend Development with Design / Graphics / Technology

Different Ways of declaring Types in TypeScript

TypeScript offers a powerful type-checking facility for JavaScript and a must-have tool for Front-end projects. If you are starting out, it may feel like introducing unnecessary overhead, but its usefulness is felt once you have built up a decent codebase. The additional work you put in for declaring the types acts as your own guard rails, preventing you from obvious type errors.

In this post I want to cover the various options of declaring these types, from the anonymous-types to concrete classes and modules. Knowing these options allows you to pick the right style of declaring types.

Image showing the various options in the order of simplicity

Anonymous types

Since TypeScript follows a structural type system, you are not required to name your types when declaring them. This gives you the benefit of creating compatible types purely on the basis of having matching members.

The TypeScript spec calls these as Object Type Literals, however I prefer to call them Anonymous Types, which is much easier to remember too!.

import React from 'react';

const PersonComponent: React.StatelessComponent<{
    name: string;
    email: string;
    avatarUrl: string;
}> = ({ name, email, avatarUrl }) => {
    return <div>{/* Person VDOM */}</div>;
};

In the above code, the props for the PersonComponent is declared with an anonymous-type. This kind of declaration usually works best for local types that do not need to be shared outside.

Type Aliases

With Type Aliases we adopt a name to represent the type. This is useful when the same structure gets repeated at multiple places. You can save yourself some typing (pun intended 😀) and use the name instead.

type Person = {
    name: string;
    email: string;
    avatarUrl?: string;
};

Type Aliases are strikingly similar to interfaces but lack a few important abilities:

  • They cannot act as a class-type that can be extended or an interface-type that can be implemented
  • They are not open-ended, which means multiple type-alias declarations with the same name are treated as error rather than being merged into a single declaration. On the other hand, multiple same-name interface declarations are merged into a single declaration.

They also work well for domain-specific data-types:

type UrlString = string;
type PersonId = string | undefined | null;
type SessionToken = string;
type AuthChangedCallback = (username: string, token?: SessionToken) => void;

Interfaces

As mentioned earlier, Interfaces are more useful than Type Aliases and should normally be your default choice for declaring the type. Both interfaces and type-aliases are compile-time-only constructs and there is no footprint when the final JavaScript is emitted.

This particular characteristic is also known as zero-cost abstraction.

interface Person {
    name: string;
    email: string;
    avatarUrl?: string;
    address?: Address;
}

interface Address {
    line1: string;
    line2?: string;
    city: string;
    state: string;
    country: string;
    pincode: string;
}

Interfaces work great when you are modeling your domain and identifying the various entities and value objects in the system. This frees you from the implementation quirks and focus more on the Ubiquitous Language of the domain.

Classes

Classes are concrete types and can be instantiated with the new operator. A React component is a good example where you do have to declare a concrete type and even extend the framework type: React.Component.

import React from 'react';

class Avatar extends React.Component {
    public render() {
        const { source, description } = this.props;

        return <img src={source} alt={description} style={{ width: 64, height: 64 }} />;
    }
}

Classes can also be guarded with an interface with constructor signatures to ensure type-safe creation, as seen below:

interface Constructor<T> {
    new (greeting: string): T;
}

interface Greeter {
    greet(name: string): string;
}

class Hello implements Greeter {
    constructor(public greeting: string) {
        this.greeting = greeting;
    }

    public greet(name: string) {
        return `${this.greeting} ${name}`;
    }
}

const DefaultGreeter: Constructor<Greeter> = Hello;
const x: Greeter = new DefaultGreeter('hi');

Local Function declarations

A little known fact about TypeScript is that you can also declare anonymous-types, type-aliases, interfaces, enums, classes inside a function. They are not just limited to top level declarations.

Types declared inside functions are local and useful for local type checking.

function authenticateUser(username: string) {
    type Credentials = {
        token: string;
        username: string;
    };

    const creds: Credentials = { username, token: sessionService.getToken() };
    authService.authenticate(creds);
}

In fact, you can even have declarations local to a block:

function f() {
    if (true) {
        interface T {
            x: number;
        }
        let v: T;
        v.x = 5;
    } else {
        interface T {
            x: string;
        }
        let v: T;
        v.x = 'hello';
    }
}

Namespaces

Namespaces are primarily meant to collect a set of related types and put them under a common bucket. It is an organizational tool in TypeScript and allows breaking up the domain into many cohesive sub-domains. Types inside a namespace are referenced with a dotted notation such as <Namespace>.<Type>

Here’s a collection of types to track the state of the React Native app, organized inside the namespace:

// my-app-state.ts
export namespace MyAppState {
    export let offline = false;
    export let active = true;

    export function setup() {
        // setup code to add listeners for the offline + active state
        // using AppState and NetInfo
    }

    export class ActionTracker {
        /* track user actions by sending telemetry requests */
    }
}

// app.ts
import { MyAppState } from './my-app-state.ts';

console.log(MyAppState.offline);

Note the need to export the members of the namespace to be available outside. Else they would be local and private to the namespace.

Module Types

This is the highest level of organization that is possible in TypeScript. You can collect all of the related namespaces and types under a module and can even import into a project purely for the type definitions. For example, the modules under the @types npm scope is meant for this purpose, such as @types/react, @types/react-dom, etc.

Moving the domain-types of a project to a module is a good use of this construct. If you are building a set of related apps, it makes sense to move the domain-specific types to a module and import into the projects.

Modules allow declaration merging. This lets you augment the type-definitions of a third-party module, in case you find something missing.

// custom-module/index.d.ts
declare module 'custom-module' {
    export class CustomConfigurator implements Configurator {
        /* ... */
    }

    export interface Configurator {}

    export enum ConfigurationKey {
        name = 'name',
        place = 'place',
        animal = 'animal',
        thing = 'thing',
    }
}

// app.js
import { Configurator } from 'custom-module';

If you are authoring a library or a private package for internal use, you can use declaration-files (*.d.ts) to specify all the types. TypeScript will automatically look for these files when importing the library into your main project.

Summary

There is a spectrum of options available for declaring types in TypeScript. Now that you know the various possibilities, you can pick and choose the right style based on your need.

Image showing the various options in the order of simplicity

Different Ways of declaring Types in TypeScript
Pavan Podila
Pavan Podila
October 5th, 2018