Sunday, February 07, 2021

Using Literal and Template Literal Types in TypeScript

This blog post will look at some of the use cases of literal and template literal types in TypeScript. It is a continuation of Literal and Template Literal Types, which introduces the language feature and it is a part of the Introduction to Advanced Types in TypeScript series.

This post will look at the following use cases:

  1. Compile time spell checker
  2. Alternative to enum
  3. Discriminated Union Pattern
  4. Stricter Type Constraints and Type Level Computation

Compile time spell checker

As shown above, trying to use variables with literal types can reduce their utility. But one place where literal types really shine, and where they provide a tremendous amount of utility is when they are used to ensure correctness within HTML templates or where literal string values need to be used within code. 

For example, using any of your modern day JavaScript library/framework (eg react, angular, vue etc) a component <direction/> can be defined to have a cardinal attribute which can only have either North, South, East, or West as valid attribute values. For example:

<direction cardinal=”north” /> // valid
<direction cardinal=”south” /> // valid
<direction cardinal=”east” /> // valid
<direction cardinal=”west” /> // valid
Such a constraints can be enforced using literal template such that when the <direction/> component is used within HTML template, such mistake is caught at compile time: 

a<direction cardinal=”top” /> // won’t compile
<direction cardinal=”norht” /> // won’t compile

Alternative to enum

TypeScript's goal is to extend JavaScript with static type guarantees and not necessarily evolve JavaScript into a different language. Because of this, TypeScript generally avoids introducing new programming language constructs. Unfortunately, due to various historical reasons, TypeScript hasn’t been able to keep this promise and has introduced language features not present in JavaScript. One of such features is the introduction of Enum. Namespaces is another example of where TypeScript has deviated from this.

In TypeScript you can define Enums as follows:
enum RGB {
 Red, Green, Blue
}
This definition gets transpiled into JavaScript code. For example, the above RBG definition will produce the following JavaScript:


"use strict";
var RGB;
(function (RGB) {
    RGB[RGB["Red"] = 0] = "Red";
    RGB[RGB["Green"] = 1] = "Green";
    RGB[RGB["Blue"] = 2] = "Blue";
})(RGB || (RGB = {}));
This shows that this construct does more than just provide static type guarantees, it introduced a new language construct, enums which does not exist in JavaScript, hence will have to be emulated in normal JavaScript code on transpilation. A trick that can be used to prevent this emulation on the JavaScript level while keeping the static type guaranteed by TypeScript is to prefix the  enum definition with const. For example:
const enum RGB {
    Red,
    Green,
    Blue,
}
Although this trick works, it does not remove the fact that enum is a language construct not found in JavaScript. It is not strictly about static typing. To have a more idiomatic approach we can use literal types.

Literal types together with Union Types provide another mechanism for achieving the functionality enum provides without compromising on the idea of not introducing new language features. 

The above  RGB enum can be defined using literal types as follows:

type RGB = "Red" | "Green" | "Blue" 
And unlike enum, this is strictly a type-level definition that is transpiled away by TypeScript.

Discriminated Union Pattern

Stricter error handling can be achieved with literal types when used together with union types and type narrowing. This emulates Discriminated Union, which provides compile-time guarantees that all possible values are handled, preventing a situation where an unhandled case leads to runtime error

To illustrate, let's have the following union type definition:

type Square = {
    kind: "square";
    size: number;
}

type Rectangle = {
    kind: "rectangle";
    width: number;
    height: number;
}

type Shape = Square | Rectangle;

Shape is a union type, constructed from Square and Rectangle. Notice that both definitions of Square and Rectangle contains a kind property which is a literal type.  This kind property is what is used for further labeling and differentiation of the types that make up the Shape union type. This is where the discrimination comes from in Discriminated Union. 

What does this buy us? 

To see this, let us define a function that takes a value of Shape type and compute the area. We define as follows:

function area(s: Shape) {
    switch(s.kind) {
        case "square":
        return s.size * s.size;
        case "rectangle":
        return s.length * s.width;
    }
}

Nothing new, in the definition above, we are using the kind property to narrow the type so that the appropriate code within the switch branch can be executed.

We can use the defined function and it works as expected:


area({kind:"square", size: 5}) // computes 25
area({kind:"rectangle", length: 4, width: 5}) // computes 40

Now let us imagine the requirement changes and we need to also handle circles, we can extend the type definition as follows:

type Square = {
    kind: "square";
    size: number;
}

type Rectangle = {
    kind: "rectangle";
    length: number;
    width: number;
}

type Circle = {
    kind: "circle";
    radius: number;
}

type Shape = Square | Rectangle | Circle;

But the problem now is, our area definition does not contain code that handles the situation where the value passed in is a circle. And what is worse is the fact that TypeScript compiler was not able to point out this error, leaving us to learn about the omission only at runtime. When we attempt to compute the area of a circle, we get undefined in return, a subtle way to introduce bugs.

area({kind:"circle", radius: 5}) // returns undefined

To enable TypeScript to be able to warn us of these kinds of scenarios where not all possible values are handled, we complete the Discriminated Union pattern by defining a utility function that we use as the default case in the switch statement. 

This utility function takes a value of never, which means it is not being expected to be called, hence if it is called by any value, it will lead to a compile-time error. 

The utility function can be defined as follows:

function halt(_: never): never {
    throw new Error("error")
}

We can then update the definition of area to use the halt function as follows:


function area(s: Shape) {
    switch(s.kind) {
        case "square":
        return s.size * s.size;
        case "rectangle":
        return s.length * s.width;
        default:
        return halt(s);
    }
}

With the above, TypeScript compilation now fails with the following error:

Argument of type 'Circle' is not assignable to parameter of type 'never'

Serving as a reminder that the Circle case is not being handled. To fix the error area needs to be updated as follows:


function area(s: Shape) {
    switch(s.kind) {
        case "square":
        return s.size * s.size;
        case "rectangle":
        return s.length * s.width;
        case "circle":
        return Math.PI * s.radius * s.radius
        default:
        return halt(s);
    }
}

It is worth noting that TypeScript provides another avenue of catching these kinds of error that does not require the need to emulate Discriminated Unions and that is by enabling the noImplicitReturns compiler flag. With this flag turned on, the compiler would be able to warn that area function can potentially return undefined due to the fact that not all values are handled.
 
It might also be good to know that Discriminated unions are also called "tagged unions" or "sum types" and they are a native feature in languages like Ocaml, F#, PureScript, Haskell, etc. They have been used to successfully avoid the Null Reference problem in these languages.

Stricter Type Constraints and Type Level Computation

Literal type and template literal type can be used to create more sophisticated constraints on the type level.

For example, template literal types can be used to enforce that a string value should always be a byte string as follows:
type Bit = 0 | 1
type Byte = `${Bit}${Bit}${Bit}${Bit}${Bit}${Bit}${Bit}${Bit}`


function byteToDecimal(input: Byte) {
  console.log(parseInt(input, 2))
}

// compiles alright
byteToDecimal("11011101")

// won't compile since string passed in is not a byte
byteToDecimal("1111111")
More sophisticated and mind-blowing type-level constraints can be achieved with the use of literal types, template literal types, and other features of the TypeScript type system. You can find more of such constraints in the Awesome Template Literal Types repo. 

This post is part of a series. See Introduction to Advance Types in TypeScript for more posts in the series.


I am writing a book: TypeScript Beyond The Basics. Sign up here to be notified when it is ready.

No comments: