typescript: type guards vs const assertions

2020-08-06

 | 

~5 min read

 | 

914 words

I was discussing a refactor for some types recently with a colleague when he suggested I use a const assertion. There are several good articles on the web already about the value in the const assertion, such as this one from LogRocket, but it took a little playing around with it myself before it really clicked.

Specifically, it took comparing it to Type Guards, an alternative approach to get the type safety, and one I’d written about previously related to using useReducer.

Below, I’ll walk through an example, first without Type Guards or const assertions, then looking at each approach in turn. From the final result, it’s clear that the const assertion is significantly less code while providing the same benefits in a format that’s easier to reason through.

Base Example

In my particular case, we were looking at Redux Actions and Reducers, so that’s going to be the basis for my example - though certainly Redux is not necessary and if the concept is new - feel free to imagine all of these as regular functions (which is what they are after all).

basicRedux.ts
const enum ActionTypes {
    Login = "Login",
    SetAge = "SetAge",
}

function login(username: string) {
    return {
        type: ActionTypes.Login,
        payload: { username },
    }
}

function setAge(age: number) {
    return {
        type: ActionTypes.SetAge,
        payload: { age },
    }
}

type Actions = ReturnType<typeof login> | ReturnType<typeof setAge>

function reducer(action: Actions) {
    console.log(action.payload.age) // typescript complains because it cannot determine if `age` is available
    switch (action.type) {
        case ActionTypes.Login:
            console.log(action.payload.username) // typescript complains because it cannot determine if `username` is available
            break
        case ActionTypes.SetAge:
            console.log(action.payload.age) // typescript complains because it cannot determine if `age` is available
            break
    }
}

In this first iteration, Typescript will complain loudly when it gets to the reducer because it’s unable to determine which Action type it is (though Typescript is aware that each case is a type: Actions, it’s not specific).

Type Guards To The Rescue

One solution here is to use a Type Guard, though it’s a verbose solution. To get them to work, we need to provide not only the Type Guard functions themselves, but also interfaces for each of the Actions:

typeGuardedRedux.ts
const enum ActionTypes {
    Login = "Login",
    SetAge = "SetAge",
}

function login(username: string) {
    return {
        type: ActionTypes.Login,
        payload: { username },
    }
}

function setAge(age: number) {
    return {
        type: ActionTypes.SetAge,
        payload: { age },
    }
}

export interface Action {
    type: ActionTypes
}

export interface Login extends Action {
    payload: { username: string }
}

export interface SetAge extends Action {
    payload: { age: number }
}

// The Type Guard Functions
function isLogin(action: Action): action is Login {
    return action.type === "Login"
}
function isSetAge(action: Action): action is SetAge {
    return action.type === "SetAge"
}

Then, to make use of these Type Guards, we would refactor the reducer and replace the switch statement with an if/else chain:

typeGuardedRedux.ts
//...
function reducer( action: Actions ) {
    console.log(action.payload.age); // typescript complains because it cannot determine if `age` is available
-     switch (action.type) {
-         case ActionTypes.Login:
-             console.log(action.payload.username); // typescript complains because it cannot determine if `username` is available
-             break;
-         case ActionTypes.SetAge:
-             console.log(action.payload.age); // typescript complains because it cannot determine if `age` is available
-             break;
+     if(isLogin(action)){
+         console.log(action.payload.username)
+     }
+     if(isSetAge(action)){
+         console.log(action.payload.age)
     }
}

This works - but it’s a lot more code, requiring a Type Guard for each type. This can get overwhelming, particularly if there are dozens or hundreds of elements in the enum - which in a large application is not unheard of.

const Assertion

A simpler alternative is to cast the Action as a const which prevents a widening of the type.

From the release notes for Typescript 3.4 in which const assertions were added:

const assertions

TypeScript 3.4 introduces a new construct for literal values called const assertions. Its syntax is a type assertion with const in place of the type name (e.g. 123 as const). When we construct new literal expressions with const assertions, we can signal to the language that

  • no literal types in that expression should be widened (e.g. no going from “hello” to string)
  • object literals get readonly properties
  • array literals become readonly tuples

What this really means for our purposes, is that we can eliminate all of the boiler plate around providing interfaces for the Actions or defining the Type Guards and still receive the type safety from Typescript:

const enum ActionTypes {
    Login = "Login",
    SetAge = "SetAge",
}

function login(username: string) {
    return {
        type: ActionTypes.Login,
        payload: { username },
    } as const
}

function setAge(age: number) {
    return {
        type: ActionTypes.SetAge,
        payload: { age },
    } as const
}

type Actions = ReturnType<typeof login> | ReturnType<typeof setAge>

function reducer(action: Actions) {
    console.log(action.payload.age) // Typescript still complains here because it does not know the action type
    switch (action.type) {
        case ActionTypes.Login:
            console.log(action.payload.username) // Typescript knows the Action type is login, therefore there will be `username` on the payload
            break
        case ActionTypes.SetAge:
            console.log(action.payload.age) // Typescript knows the Action type is SetAge, therefore there will be `age` on the payload
            break
    }
}

Wrap Up

By restricting the types to its literal format or read only for arrays and objects, the const communicates to Typescript the strict type shape allowing access to type specific properties with confidence and safety. Hooray!


Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!