TypeScript 4.4 - Be on your guard

In this post, in celebration of the release of TypeScript 4.4, I explore Type Unions in TypeScript and how the language uses Type Guards and Control Flow Analysis to automatically refine a variable's type.

I'll be covering:

  • What are Type Unions
  • Type Guards and Control Flow Analysis
  • 4.4's CFA for Aliased Conditions and Discriminants

Introduction

I've talked before about the pragmatism of TypeScript, augmenting JavaScript with type safety for an improved development experience. But it's much more than a thin layer of static types on top of JavaScript, it has an incredibly powerful type system. It's possible in TypeScript to derive new types from existing types using type unions, type intersections, mapped types and conditional types. This type programming capability allows us to cut down on code duplication and have a single source of truth when defining types.

interface Employee {
    name: string
    age: number;
    dob: Date;
}

// Type Intersection
type EmployeeWithId = Employee & { id: string };
// This type will be equivalent to
// {
//    id: string;
//    name: string
//    age: number;
//    dob: Date;
// }

// Readonly uses Mapped Types
type ReadonlyEmployee = Readonly<Employee>;
// This type will be equivalent to
// {
//    readonly name: string
//    readonly age: number;
//    readonly dob: Date;
// }

// A Mapped Type with a Conditional Type allows for interesting transformations
type DateToString<T> = {
    [P in keyof T]: T[P] extends Date ? string : T[P];
};

type EmployeeJson = DateToString<Employee>
// This type will be equivalent to
// {
//    name: string
//    age: number;
//    dob: string; // Date has changed to string
// }

In this post, I'm going to delve into Type Unions since TypeScript 4.4 augments the compiler with support for more natural use of union type checks. If you'd like a post on any of the items above, be sure to like and leave a comment.

Type Unions Are Awesome

Type Unions allow us to define a new type which represents any one of a set of possible types. For example:

type Primitive = 
    | string
    | number
    | boolean;

let x: Primitive;

Here, the type union Primitive means that x may refer to either a string OR number OR boolean value.

x = 'Hello';
x = 123;
x = false;

x = new Date(); // Compiler Error

This is especially important in a JavaScript world where API’s frequently contain functions that accept or return values of different types. Libraries exploit dynamic typing to compact many different behaviours into the a single function. With unions in TypeScript, instead of going to the highest abstraction of any or unknown we can be safe and restrict to a set of valid types.

But if x can be any one of these possible types, what can we safely do with x. It turns out, in this case, not very much:

Autocomplete on Union Variable

The auto-complete shows that all we can do is valueOf, toString and toLocaleString. The only APIs that we can safely use with a union are those that are common to ALL members of the union.

We can of course use a type assertion to a specific type:

console.log(x.toFixed(2)); // Compiler Error

const y = x as number;
console.log(y.toFixed(2)); // Valid

But how would we know that our assertion is correct, that the value is the correct type? Of course if we had any doubt, in JavaScript, we would write some code to test the data.

if (typeof x === 'number') {
    const y = x as number;
    console.log(y.toFixed(2)); // We can now safely use 'number' operations
}

TypeScript simplifies this kind of code by examining the control flow of our program (ifs, switches etc) and automatically refines or narrows the type of the variable (what may be referred to as a smart cast in other languages). So, inside the if block, it automatically interprets x as a number.

if (typeof x === 'number') {
    console.log(x.toFixed(2)); // 'x' can safely use 'number' operations 
}

What's more, on the else block for this if, the type of x would be the remainder of the union. So, in this case, it would be narrowed to string | boolean.

What the compiler is doing here is called Control Flow Analysis (CFA) and the condition we have written is called a Type Guard. The compiler can analyse control flow via if, switch, throw, return and operators such as logic (&&, ||), assignment, equality and ternary (? :).

The real elegance of this is that we are writing the same code we would write in JavaScript - notice that there are no type annotations above. The difference in TypeScript is we get type safety and better tooling.

Types of Type Guard

Using typeof is just one guard example. Let's break down the types of type guards. I've resisted categorising them as types of types of type guard was too hard to type. TYPE!

null Guard

null checking is one of the most common guards.

const element: HTMLElement | null = document.getElementById('target');

element.innerText = 'Hello'; // Compiler error - null in union

if (element !== null) {
    element.innerText = 'Hello'; // null removed from union 
}

Of course we could simplify this using optional chaining:

element?.innerText = 'Hello';

typeof Guard

We've already seen the typeof guard in action. Here's another example using a switch:

// Note that the type of 'primitive' in each branch is different
switch (typeof primitive) {
    case "number": return primitive.toFixed(2);
    case "string": return primitive.toUpperCase();
    case "boolean": return !primitive;
    default: throw new Error("Unexpected type") 
}

Remember that typeof can only tell us if a type is string, number, boolean, symbol, function, bigint, undefined. Everything else will return "object".

instanceof Guard

With instanceof we can test if an object is an instance of, or derived from, a class.

function setupInput(input: HTMLInputElement | HTMLTextAreaElement) {
    input.value = ''; // Valid since value is common to both types

    if (input instanceof HTMLTextAreaElement) {
        // These properties are only valid for HTMLTextAreaElement
        input.rows = 25;
        input.cols = 80;
        input.wrap = 'hard';
    } else {
        // These properties are only valid for HTMLInputElement
        input.width = 400;
        input.height = 50;
    }
}

Note, all the property sets inside the if/else blocks would fail outside as they are not common to both types.

in Guard

The in operator allows us to test an object for the existence of a member. In the code below, only HTMLTextAreaElement has a rows property so by testing for its existence the compiler determines the type of the input variable on each branch and narrows the type.

function setupInput(input: HTMLInputElement | HTMLTextAreaElement) {
    if ('rows' in input) {
        // These properties are only valid for HTMLTextAreaElement
        input.rows = 25;
        input.cols = 80;
        input.wrap = 'hard';
    } else {
        // These properties are only valid for HTMLInputElement
        input.width = 400;
        input.height = 50;
    }
}

Note, in can only be used if ALL members of the union are object types (not primitives).

Discriminated Union Guards

A Discriminated Union is a design where the types within a union each contain a member that uniquely identifies or allows the compiler to discriminate that type. For example:

interface Dog {
    kind: 'Dog';
    bark(): string;
}

interface Cat {
    kind: 'Cat';
    purr(): string;
}

interface Fox {
    kind: 'Fox';
}

type Animal =
    Dog
    | Cat
    | Fox;

Here, the kind property is common to all types so we can access it on a variable of type Animal. TypeScript can use the comparison of this property with possible values to narrow the type appropriately.

// animal has type Animal or 'Dog | Cat | Fox'
const animal: Animal = readAnimal();

if (animal.kind === "Fox") {
    // animal has type 'Fox'
    throw new Error('What does the Fox say?');
}
// 'Fox' removed because of throw above

// animal has type 'Dog | Cat'
return animal.kind === "Cat" 
           ? animal.purr()    // animal has type 'Cat'
           : animal.bark();   // animal has type 'Dog'

Again, the great thing here is that we have not written any type annotations or assertions but the compiler is still doing error checking and the IDE can give us lots of auto-complete and support.

User-Defined Guards

Prior to TypeScript 4.4, user-defined guards were the only way to improve the readability of this kind of code. User-defined guards allow us to break our type checking logic and guards out into named functions.

For example, consider the code to check our HTML element earlier:

if ('rows' in input) {
    // input has type HTMLTextAreaElement
}

Quite often I would break a check like this out into a function, but the compiler will not drill into the function to find the type guard so narrowing does not take place.

function isTextArea(input: HTMLInputElement | HTMLTextAreaElement): boolean {
    return 'rows' in input;
}

if (isTextArea(input)) {
    // input still has union type
    input.rows = 25; // Compiler error
}

What we must do is change the return type from boolean to a type predicate. This allows us to provide the compiler type information about an input parameter if our boolean function returns true.

function isTextArea(input: HTMLInputElement | HTMLTextAreaElement): input is HTMLTextAreaElement {
    return 'rows' in input;
}

if (isTextArea(input)) {
    // input has type HTMLTextAreaElement
    input.rows = 25; // Valid
}

Assertion Functions

We can use Assertion Functions in a similar way. An Assertion Function informs the compiler that it will throw an error if some condition is not true. We can use them to assert that an input parameter has a certain type and then CFA will narrow appropriately. The syntax is similar to a user-defined type guard except we prefix asserts to the type predicate.

function assertIsTextArea(input: HTMLInputElement | HTMLTextAreaElement): asserts input is HTMLTextAreaElement {
    if (!('rows' in input)) {
        throw new Error("Expected a HTMLTextAreaElement");
    }
}

assertIsTextArea(input);

// If we get to this line, no exception has been thrown
// Therefore, input has type HTMLTextAreaElement
input.rows = 25;

The advantage here is it removes indentation and still narrows. This can be really useful for developer assertions at the top of functions (older meaning of guards) or within tests.

What TypeScript 4.4 Improves

Although User-Defined Type Guards and Assertion Functions help with readability they are a coarse tool. What the TypeScript 4.4 compiler brings to the table is the ability to retain Control Flow Analysis information for variables so we can write more natural code and retain the narrowing functionality.

For example, the following code would not work prior to TypeScript 4.4 since the guard has been broken out to a variable:

const isTextArea = 'rows' in input;

if (isTextArea) {
    // Narrowing has NOT taken place
    input.rows = 25; // Compiler error
}

In TypeScript 4.4, this code just works. The variable isTextArea retains the information that it denotes whether input is a HTMLTextAreaElement and this can be picked up by CFA. It calls these Aliased Conditions.

The conditions can use all the guards described so far, can incorporate multiple conditions and even works transitively.

const isDog = animal.kind === 'Dog';
const isCat = animal.kind === 'Cat';
const canSpeak = isDog || isCat; // Also retains CFA information

if (!canSpeak) {
    throw new Error('What does the Fox say?');
}

return isCat 
           ? animal.purr() 
           : animal.bark(); // Without the throw above this would be `Fox | Dog`

The canSpeak variable is an "or" of two other variables, but the type information from these two variables is combined and applied to canSpeak.

Limitations

The following limitations are not a criticism per se but merely my experimentation with the feature to understand it better.

Only Conditions are Aliased

This type information aliasing only works on conditions. If we create variables to contain key information and later build conditions from these, then the type guard behaviour does not work. For example, here we store the typeof result in a variable.

let primitiveType = typeof primitive;
switch (primitiveType) {
    case "number": return primitive.toFixed(2);    // No narrowing - compiler error
    case "string": return primitive.toUpperCase(); // No narrowing - compiler error
    //...
}

Only Top Level Variables

TypeScript is clever enough that normal type guards support narrowing with nested properties. For example:

interface InputArea {
    control: HTMLTextAreaElement | HTMLInputElement;
}

interface Component {
    inputArea: InputArea;
}

function setupComponent(component: Component) {
    if ('rows' in component.inputArea.control) {
        // These properties are only valid for HTMLTextAreaElement
        component.inputArea.control.rows = 25;
        component.inputArea.control.cols = 80;
        component.inputArea.control.wrap = 'hard';
    } else {
        // These properties are only valid for HTMLInputElement
        component.inputArea.control.width = 400;
        component.inputArea.control.height = 50;
    }
}

Here we have a type guard against property nested 2 levels into an object but the compiler still narrows it. However, this does not work with the new aliased conditions (even with a single level of nesting). It must be a top level variable.

function setupComponent(component: Component) {
    let isUsingTextArea = 'rows' in component.inputArea.control;

    if (isUsingTextArea) { // Narrowing does not occur
        component.inputArea.control.rows = 25; // Compiler error
        // ...

Transitive Depth Limit

There is also a limit to how far the compiler will apply the transitive rule. I was curious about what this limit was so wrote the following (horrible) code:

interface A {
    kind: 'A';
    child: Node;
}

interface B {
    kind: 'B';
    child: Node;
}

interface C {
    kind: 'C';
    child: Node;
}

interface D {
    kind: 'D';
    child: Node;
}

interface E {
    kind: 'E';
    child: Node;
}

interface F {
    kind: 'F';
    child: Node;
}

interface Terminal {
    kind: 'Terminal';
}

type Node = A | B | C | D | E | F | Terminal;

declare const root: Node;

So we have a large discriminated union where all members have a child property except one, Terminal. Next I created a series of conditions which each relied on the previous.

const isA = root.kind === 'A';
const isAorB = isA || root.kind === "B";
const isAorBorC = isAorB || root.kind === "C";
const isAorBorCorD = isAorBorC || root.kind === "D";
const isAorBorCorDorE = isAorBorCorD || root.kind === "E";
const isAorBorCorDorEorF = isAorBorCorDorE || root.kind === "F";

My goal was to see for which variable we lost the type guard information that ensure root is not the Terminal type. What I found was that the following lines work as root is known to have a child:

const childIsA1 = isAorB && root.child.kind === 'A';
const childIsA2 = isAorBorC && root.child.kind === 'A';
const childIsA3 = isAorBorCorD && root.child.kind === 'A';
const childIsA4 = isAorBorCorDorE && root.child.kind === 'A';

and the following line does not as it does not know what root is:

const childIsA5 = isAorBorCorDorEorF && root.child.kind === 'A';
//                                           ^^^^^ - Error 

Indicating 4 levels of nested aliasing. I can't see this being an issue - I was just curious and thought it would be fun to test.

Conclusion

I hope you find Type Guards as cool as I do. The updates for TypeScript 4.4 are not very flashy but this change is incredibly useful for keeping code clean and safe without adding lots of type annotations.

If you haven't already done so, check out my posts showing some of the useful features in TypeScript 4.1, TypeScript 4.2 and TypeScript 4.3.

Also, be sure to check out our TypeScript course. We're also happy to deliver Angular & React training using TypeScript. We deliver virtually to companies all over the world and are happy to customise our courses to tailor to your team's level and specific needs. Come and check us out to see if we can help you and your team.

About the Title Image

The title image for the post is of Brendan Gleeson, taken from a movie called 'The Guard'. It's a dark comedy about a policeman in Ireland. The police service in Ireland is called "An Garda Síochána" (The Guardians of the Peace in Irish) with its members referred to as 'Guards'. I like tenuous movie references.

Article By
blog author

Eamonn Boyle

Instructor, developer