Writing better TypeScript

4/26/2021 by Stefan Bauer
This post is over a year old. Some of this information may be out of date.

I just finished reading two books on TypeScript that I can wholeheartedly recommend. These books are "Programming TypeScript" by Boris Cherny (O'Reilly, 2019) and "Effective TypeScript" by Dan Vanderkam (O'Reilly, 2019). Both books are excellent and I learned a lot from reading them although I have been working with TypeScript for years. Therefore, I felt like it was a good idea to write about some of my learnings. In this way, I can read up on things and share knowledge with others. Everything you can read here (and much more) is explored in much more depth in these two books.

DRY your types and save work

TypeScript is great at inferring types. It never occurred to me, but it makes sense, that we should take much more advantage of this. Why type everything ourselves, if we can delegate so much work to TypeScript?

For example, you could use the typeof operator to infer an option object's type.

const options = {
    id: 1,
    active: true,
    label: 'Hello world',
};
type Options = typeof options;

Combined with the ReturnType utility, this also works for functions or methods.

class Foo {
    readonly hello: string;
    readonly foo: boolean;
    readonly bar: number;

    constructor(options: ReturnType<typeof Foo.defaultOptions>) {
        // In your IDE or editor, hover over options to see the magic in action...
        this.hello = options.hello;
        this.foo = options.foo;
        this.bar = options.bar;
    }

    static defaultOptions() {
        return {
            hello: 'world',
            foo: true,
            bar: 5,
        };
    }
}

There is, of course, much more that we can do to save work and make our types safer. For example, unions can be used to extract information across multiple types.

interface LoadAction {
    type: 'load';
    callback: () => void;
}

interface SaveAction {
    type: 'save';
    callback: () => void;
}

type Action = LoadAction | SaveAction;
type ActionType = Action['type'];
// You can also use a utility for this.
type ActionTypeAlternative = Pick<Action, 'type'>;

It is also really easy to create subsets of types.

interface State {
    userId: string;
    pageTitle: string;
    pageContent: string;
    recentFiles: string[];
}

type NavigationBarState = {
    [k in 'userId' | 'pageTitle']: State[k];
}
// This yields { userId: string, pageTitle: string }.
// Again, you could use a utility to achieve the same thing.
type NavigationBarStateAlterantive = Pick<State, 'userId' | 'pageTitle'>

The main takeaway here is that we can combine TypeScript's various features to infer and generate safe types for us. We want autocomplete and we want type safety, but we also do not want to clutter our code with unnecessary repetition and superfluous types. Of course, there are many occasions when it makes more sense to be specific. Still, I consider it best practice only to type as much as necessary.

If you want to dig a little deeper, I can recommend the following links:

Know and use the companion object pattern

I did not know about the super useful companion object pattern. Consider the following code.

export type Currency = {
    unit: 'EUR' | 'USD';
    value: number;
}

type CurrencyCompanionObject = {
    DEFAULT: Currency['unit'];
    from(value: Currency['value'], unit: Currency['unit']): Currency;
}

export const Currency: CurrencyCompanionObject = {
    DEFAULT: 'USD',
    from(value: number, unit = Currency.DEFAULT): Currency {
        return { value, unit };
    },
};

This allows you to use Currency as both, a type and a utility, with one single import.

import { Currency } from './Currency';


const currencyValue: Currency = Currency.from(500, 'EUR');

With companion objects, you can create simple value types in a clean and straightforward way.

Avoid enums

I enjoy working with enums in most languages. However, in TypeScript, they come with a special cost. First, it is important to acknowledge that enums generate JavaScript code, whereas const enums do not.

const enum Hello { World }

if (0 === Hello.World) {
    console.log('Comparison 1');
}

// Transpiles to:
if (0 === 0 /* World */) {
    console.log('Comparison 1');
}

// On the other hand, this:
enum Foo { Bar }

if (0 === Foo.Bar) {
    console.log('Comparison 2');
}

// transpiles to:
if (0 === Foo.Bar) {
    console.log('Comparison 2');
}

The other thing that you should know, which is way more important, is that enums are nowhere as safe as simple union types. Just have a look at this:

enum Hello { One, Two, Three }

const a = Hello[0]; // This is fine...
const b = Hello[5]; // ... but this is also fine! Oh no!

When you need a map, use a map. They are not as quirky as enums and in this way you do not have to remember any enum-specific pitfalls (such as runtime code and declaration merging).

Check out the TypeScript documentation for more on enums.

Write safer objects

This is just a small tip, but I did not know about it. You can create safer objects with as const.

const val = {
    foo: 0,
    bar: 1,
};

val = 'some string'; // Error: Cannot assign to 'val' because it is a constant.
val.foo = 2; // OK

const safeVal = {
    foo: 0,
    bar: 1,
} as const;

safeVal = 'some string'; // Error: Cannot assign to 'val' because it is a constant.
safeVal.foo = 2; // Error: Cannot assign to 'foo' because it is a read-only property.

Edit:

/u/SidewaysGate made me aware that it is easy to misinterpret my statement here. as const is of course not always the appropriate choice. It can make your objects "safer" if what you want is a "real" constant. You can read their remark here.

Know when to use types and when to use interfaces

I think that this might be a little more controversial. Generally speaking, interfaces and types can mostly do and express the same. However, there are some differences and those are best illustrated with code.

First, let's look at similarities.

interface IState {
    firstname: string;
    lastname: string;
    fullName(): string;
}

type TState = {
    firstname: string;
    lastname: string;
    fullName(): string;
}

interface IBetterState extends IState {
    age: number;
}

type TBetterState = TState & { age: number; };

interface IDictionary {
    [key: string]: string;
}

type TDictionary = { [key: string]: string }

So much for the similarities. You can use intersections to basically "extend" types. It might not be as pretty, but it is readable and easy to understand. However, there are some things that you can express with types that you cannot with interfaces. Consider the following examples.

// Consider this combination of union and intersection.
type Input = { /* ... */ };
type Output = { /* ... */ };
type NamedVariable = (Input | Output) & { name: string; };

// Also, interfaces are not suitable for tuples.
type NormalTuple = [string, boolean];

// You COULD fake something like it, although this is more
// of a hack and removes all of the native methods such as 
// `concat` that a normal tuple can use.
interface ITuple {
    0: string;
    1: boolean;
    length: 2;
}

Interfaces have other things going for them, though. Declaration merging is a thing and it allows authors to let their users extend interfaces without any hassle.

interface IExtendMe {
    foo: number;
    bar: number;
}

interface IExtendMe {
    status: boolean;
}

const extendMeInstance: IExtendMe = {
    foo: 1,
    bar: 2,
    status: true,
};

So, what is the verdict? I think it is the following.

  • If a codebase uses types, prefer types.
  • If a codebase uses interfaces, prefer interfaces.
  • If you have the choice, prefer types.

Generally speaking, you can use both and in case you cannot, you do not have a choice anyway. I will try to use types instead of interfaces in future projects, but I will certainly not attempt to migrate old codebases.

Edit:

Apparently, the TypeScript team has its own take on this topic. Thanks to /u/grumd and /u/surface1030_b for pointing this out.

Test your types

Did you know that you can and possibly should unit test your types? This can be especially helpful if you are writing a library or other code where you have to be sure that your contracts work as intended.

Check out this GitHub page if you want to learn more. Here is a code snippet so that you know what to expect (no pun intended):

import { expectType, expectAssignable } from 'tsd';
import concat from '.';


expectType<string>(concat('foo', 'bar'));
expectAssignable<string | number>(concat('foo', 'bar'));

Conclusion

There was of course much more that I learned from those books. However, these were some points that I think deserve more recognition and adoption. In general, I think that there is still so much to explore with TypeScript. It is such a powerful and fun language, and I am already looking forward to future projects.

Edit:

I posted a link to this article over at Reddit and it sparked quite an interesting discussion. You can find the comment section of my post in /r/typescript here. Thank you all for your feedback, it is very much appreciated.