Nominal Types in TypeScript

TypeScript uses a structural type system. That means compatibility between types is determined by their shape. This stands in contrast to nominal type systems, where compatibility is determined by the identity of the types themselves.

Here’s a common toy example that illustrates structural typing:

type Point2D { x: number; y: number; }

type Point3D { x: number; y: number; z: number; }

const distance2d = (p1: Point2D, p2: Point2D): number => {
   return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
}

const p1: Point3D = { x: 0, y: 0, z: 0 };
const p2: Point3D = { x: 3, y: 4, z: 2 };

console.log(distance2d(p1, p2)); // => 5

Even though Point2D and Point3D aren’t of the same type, distance2d accepts values of type Point3D, because Point3D contains all members Point2D declares and the types of those members are again compatible.

One big advantage of structurally typed languages is that they are flexible. Types may even be interoperable across different libraries that don’t know about each other.

There are however times where this flexibility becomes a liability.

type LoggerCtx = string;

const log = (ctx: LoggerCtx, msg: string): void => {
    // implementation omitted
}

log("hash map is being rehashed", ctx); // no type error

Here log is being called incorrectly, as the logger context and the message are being passed in the wrong order. This doesn’t result in a type error though, as LoggerCtx is just an alias to a string which is of course structurally identical to a string.

For this scenario we would actually want nominal typing. Only a value with a type named LoggerCtx should be able to be passed as the first argument to log.

Note that the following doesn’t protect against casting to any, but that should be forbidden by the linter, otherwise the entire type system can be circumvented.

Branded Types

A brand is a property on a type that exists only at compile-time and is used to differentiate otherwise structurally identical types. There are a few ways of branding types.

One way is to add a __brand property with a specific value to a type.

type LoggerCtx = string & { __brand: "logger-ctx" };

log("hash map is being rehashed", ctx);
//   ^
// Argument of type 'string' is not assignable
// to parameter of type 'LoggerCtx'.
//   Type 'string' is not assignable to type
//   '{ __brand: "logger-ctx"; }'.

One minor problem with this approach is that it’s rather simple to cast a value to be compatible with it.

const ctx = maybeCtx as string & { __brand: "logger-ctx" };

Of course somebody has to do this intentionally, but I’ve seen enough code where the authors reflexively reached for as to cast whatever they had to whatever they needed whenever they encountered any type issues. At the very least such a cast should stick out like a sore thumb in a code review.

We can make this somewhat more fool proof using unique symbols though.

Note that branded types should be module-private (i.e. not exported) and only instantiable as the return type of some sort of constructor function.

declare const Brand: unique symbol;
type LoggerCtx = string & { [Brand]: void };

This type cannot be recreated without having access to Brand. There are ways to extract the type from a function signature, but that generally takes more effort than just reading the documentation of that type and instantiating it correctly.

The complete pattern look’s like this:

/** do not export */
declare const Brand: unique symbol;
/** do not export */
type LoggerCtx = string & { [Brand]: void };

/**
 * ...
 */
export const mkLogCtx = (name: string): LoggerCtx => {
    // implementation omitted
    // potentially register the context here
    return name as LoggerCtx;
}

/**
 * ...
 *
 * {@link ctx} can be created with {@link mkLogCtx}.
 */
export const log = (ctx: LoggerCtx, msg: string): void => {
    // implementation omitted
}
Naturally, branded values don’t have to come from a dedicated constructor function—any well-defined creation point works, such as a validator, an ORM operation, or any other function that can guarantee the value satisfies the branded type’s invariants.