TypeScript Nominal Types

#typescript
2021/10/08

TypeScript has an excellent type system that works well with JavaScript, in large part due to it being a Structural type system; two types are equivalent if they're structured the same, regardless of what they're called.

type A = {a: number}
type B = {a: number}

let a: A = {a: 2}
const b: B = {a: 5}
a = b // A-OK!

This is opposed to Nominal type systems which identify their types solely by their name:

Java
class A { private int a; }
class B { private int a; }

class Program {
    void run() {
        A a = new A();
        B b = new B();
        a = b; // COMPILE ERROR! Incompatible types
    }
}

However, nominal type systems do have their benefits. Let's say you're dealing with coordinates; Latitude & Longitude, and X & Y, both expressed as a tuple of numbers.

In base TypeScript, this is a problem; they're both just a 2-tuple of numbers, so they can freely passed to functions expecting the other type.

type LatLon = [number, number];
type XY = [number, number];

function fooLatLon(c: LatLon) {/* ... */}

const xyCoord: XY = [1, 2];
fooLatLon(xyCoord);
// Doesn't cause a compile error, causes big problems at runtime!

To fix this, we can emulate nominal typing by intersecting the type with a completely unique value, like a symbol.

// Base type
type Coord = [number, number];

// Nominal types
declare const LatLonGuard: unique symbol;
type LatLon = Coord & {[LatLonGuard]: true};

declare const XYGuard: unique symbol;
type XY = Coord & {[XYGuard]: true};

// Functions using the types
function fooAnyCoord(c: Coord) {/* ... */}
function fooXYCoord(c: XY) {/* ... */}
function fooLatLonCoord(c: LatLon) {/* ... */}

const latlonCoord = [20, 30] as LatLon;
const xyCoord = [1, 2] as XY;

fooAnyCoord(latlonCoord); // OK
fooAnyCoord(xyCoord); // OK
fooXYCoord(latlonCoord); // Compile error
fooXYCoord(xyCoord); // OK
fooLatLonCoord(latlonCoord); // OK
fooLatLonCoord(xyCoord); // Compile error

This has several benefits:

How does it work?

Feel free to skip any section you already know!

declare

The declare statement is normally used to "forward declare" any values that will exist at runtime, outside of what TypeScript knows. An example of this would be an untyped JavaScript file; you can declare all the values it exports and their types.

It's also a good way to make TypeScript think there's a value there, even if there isn't. In this case, we use it to make TypeScript think there's a symbol const that we can intersect with, even though it won't actually exist at runtime.

unique symbol

The unique symbol type is kind of special; it's not considered structurally equal to any other value of its type. It's the type of the values generated from Symbol() calls; completely distinct from each other.

This makes it perfect for values that need to be hygienic; there's no chance of another type made by anyone else colliding with the type of the value.

Intersection Types

Intersection types are less well known than their counterpart Union types, but they operate in roughly the same way. A type is considered structurally equivalent to an intersection type if it's structurally equivalent to all of it's component types.

type MyType = {a: 1} & {b: 2}
const x: MyType = {a: 1, b: 2} // OK

This makes it useful for specializing values by adding a discriminator, as a generic value can easily be specialized with a small intersecting value.