Russley

Blog

Tagged Types

Using branding in TS to tag a type
TypeScript
Created almost 3 years ago

Contents

The Problem

TypeScript

type Username = string;
type Street = string;

let myName: Username = "shawr";
let myStreet: Street = "90th";

// This shouldn't be allowed
myName = myStreet;

Realistically, usernames & streets shouldn't overlap.

There are a few different use cases for this.

They generally are when you have a value that you wish to have a specific meaning or format, but is otherwise indistinguishable from the primative type.

Or, put another way:
When you want to express a precondition as a type.

Defining a tagged type

TypeScript

export type Tag<T extends string> = { _tag: T };

export type Tagged<BaseType, T extends string> = BaseType & Tag<T>;

Example: Percentages

Handling percentages

Sometimes we end up juggling two different representations of a scalar value:

Yet both of these are numbers and are otherwise indistinguishable. With branded types, we can callout a percent as a distinct type; so anywhere we want to use a Percent, we can assert it as such.

TypeScript

type Percent = Tagged<number, "Percent">; // 0-100%

function calcTestPoints(points: number, pct: Percent): number {
    return (points * pct) / 100;
}
Compiling...

John got a 85% on a test worth 43 points.

TypeScript

// Bad: Argument of type 'number' is not
//      assignable to parameter of type 'Percent'.
const johnGrade1 = calcTestPoints(43, 85);

// Good:
const johnGrade2 = calcTestPoints(43, 85 as Percent);
Compiling...

Example: Usernames

Normalizing usernames

Similar idea as percentages: we have usernames, which are represented by strings.

Because usernames are often used as keys, we likely want to normalize the username so that we don't have duplicate keys for the same user.

Define a username

TypeScript

type Username = Tagged<string, "Username">; // shawr

function normalizeUsername(username: string): Username {
    return username.trim().toLowerCase() as Username;
}
Compiling...

Utility that accepts a username.

TypeScript

// Gets if username exists; CASE & SPACE SENSITIVE
async function checkIfUserExists(username: Username) {}

Sensitive to the input we provide.

We want to use the utility

TypeScript

async function createUser(username: Username) {
    // We can assume the username is
    // already normalized because of the type.
    if (await checkIfUsernameExists(username)) {
        return;
    }

    // Continue to create user.
}

We only want to normalize once.

We normalize at the boundary layer

TypeScript

// POST /user - Create a new user if it doesn't already exist.
app.post("/user", async (req, res) => {
    // ^^^^ Unsafe, unvalidated
    const user = normalizeUsername(req.username);
    // vvvv Safe, validated

    await createUser(user);
});

Because we're not actually populating the _tag field (only making TS think we are), usage of a branded type incurs no run-time penalty.