Advanced TypeScript Tips & Tricks

Advanced TypeScript Tips & Tricks

In this article, I will show you some common issues that I have encountered while writing TypeScript applications and how to avoid them. This article is not meant to teach TypeScript, and presumes you have some experience with the language.

Topics covered:

Type assertions trap

TypeScript is all about type safety and trying to catch developer's mistakes at compile time, and not at runtime. TypeScript has two ways of assigning a value to a variable and giving it a type:

  • Type assertion -> override type that TypeScript has inferred
  • Type declaration -> assign type when declaring the variable

Imagine we have the following interface defined:

interface User {
    name: string;
    surname: string;
    address?: string;
}

and we define a new user object:

// type declaration
const user1: User = {
    name: "Adele",
    surname: "Vance"
};

// type assertion
const user2 = {
     name: "Christie",
     surname: "Cline"
} as User;

In both cases we would get IntelliSense and TypeScript warning us that we have mistyped some property:

// type declaration
const user1: User = {
    namee: "Adele", // this will give a warning
    surname: "Vance"
};

// type assertion
const user2 = {
     namee: "Christie", // this will give a warning
     surname: "Cline"
} as User;

But what happens if we change address from optional to a required parameter? One would expect that TypeScript would give a warning in both cases?

Only Type Declaration will give a warning!

When using type assertion we tell TypeScript that we know better, and it should trust us. Type assertion will only fail if we mistype something, but it won't warn us if we forget some required properties.

If you use strictNull check option, you will even be able to assign null/undefined to a required property!

This is a common mistake when trying to type a result of map filtering:

const randomStrings = ['Bob', 'Alice', 'Vance'];
const mockNames = randomStrings.map(value => ({name: value, surname: value})); // type is {name: string; surname: string}[]

// but we want to strongly type it so we get intellisense, so we try like this
const typedMockNames = randomStrings.map(value => ({name: value, surname: value} as User)); // type is User[]

It will not warn us when we make the address required. The only type safe solution is using type declaration or generic parameter of map function:

const randomStrings = ['Bob', 'Alice', 'Vance'];

const typedMockNames = randomStrings.map(value => {
     const user: User = { name: value, surname: value };
     return user;
}) // type is User[]

const typedMockNamesGeneric = randomStrings.map<User>(value => ({name: value, surname: value})) // type is User[]

Always try to use type declarations, and if you have to use type assertions be sure you know the implications when changing type definitions.

Mapping and filtering values with correct types

When working with collections it is a common use case to map them to some objects, but also to filter some values out.

For example, we have a list of user-names and their age, and we want to filter out only users above a certain age and gather their user-names:

interface User {
    name: string;
    age: number;
}

const users: User[] = [{name: 'Adele', age: 32}, {name: 'Brian', age: 70}];

const filteredUsers = users.map(user => {
    if (user.age > 60) {
        return undefined;
    }

    return {name: user.name};
}).filter(Boolean);

Using filter(Boolean) is a common way to remove all values that don't satisfy our condition, but the problem is that the type of our filteredUsers variable will be: ({name: string} | undefined)[]. Also, since filter function always removes falsy values, we know for certain that it's type will never include undefined. We could try using type assertion to fix the problem, but we had already seen in the previous example issues that may occur while using that approach.

A better solution is to define a custom type guard function that will filter out falsy values (like Boolean) but also provide us with type safety:

function isDefined<T>(value: T | undefined) value is T {
    return value !== undefined;
}

Now if we change the example to use our isDefined function, TypeScript will infer the correct type:

const filteredUsers = users.map(user => {
    if (user.age > 60) {
        return undefined;
    }

    return {name: user.name};
}).filter(isDefined); // type is now {name: string}[]

Using exhaustive checks

A great feature of TypeScript is discriminated unions. Which allow TypeScript to narrow down an object type using a literal field. This is a great way to set up some rules when creating complex systems. They allow TypeScript to warn developers in compile time of all the possible places where you are expected to implement when adding new functionality.

Let's imagine we have a simple calculator app that has a type for defining an operation and a function that will check if provided arguments are valid (real-world applications often have multiple functions that are scattered across multiple files):

interface Operation {
    type: 'add'; // our literal type used to create a discriminated union
    calculate: (args: number[]) => number;
}

function isOperationValid(operation: Operation, arguments: number[]) {
    switch(operation.type) {
        case "add":
            return true;
    }
}

We then decide that we want our calculator to support division so we change our Operation type to include it:

interface Operation {
    type: 'add' | 'division'; // expanded type to include the new operation
    calculate: (args: number[]) => number;
}

It would be great if TypeScript could warn us that we have functions that need implementing actions when dealing with the new operation type. Well since TypeScript is awesome, it can do this by using exhaustive checks.

They are extremely simple to use, we just need to add a default condition to our switch/case statements and we are good to go:

// helper function for doing exhaustive check
function exhaustiveCheck(value: never): never {
    throw new Error('Invalid type');
}

function logOperation(operation: Operation) {
    switch(operation.type) {
        case "add":
            logOperation(operation);
        default:
            exhaustiveCheck(operation.type); // we will get an error here that operation.type cannot be assigned to never
    }
}

Warning notice will disappear when we handle case for division in our logOperation function. Exhaustive check can also be used with if statements, just add it as the last line:

function logOperation(operation: Operation) {
    if (operation.type === 'add') {
        // do something
        return;
    }

    exhaustiveCheck(operation.type); // the same error as before
}

Extending enums

If you come from C# you know you can add the Description attribute to enums that you can use as display values to show to the user. TypeScript can do something similar using a feature called namespace merging.

You can add a simple getLabel function using the following code:

enum PrincipalType {
    User,
    Admin,
    Group
}

namespace PrincipalType {
    export function getLabel(p: PrincipalType) {
        switch(p) {
            case PrincipalType.Admin:
                return "Administrator";
            case PrincipalType.User:
                return "User";
            case PrincipalType.Group:
                return "Security Group";
        }
    }
}

PrincipalType.getLabel(PrincipalType.Admin); // "Administrator"

Conclusion

In this article, I showed you some TypeScript problems and how to solve them. TypeScript is an excellent tool, but like any tool, it is important that we properly wield it :). I hope that these examples helped you and that you were able to learn something from them. Till next time!