Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Nominal typing techniques in TypeScript

Many functional programming languages like Haskell or Elm have a structural type system. This perfectly lines in with the direction in which majority of JavaScript’ish community is heading. Nevertheless, every feature comes with a certain set of trade-offs. Choosing structural type system allows for a greater flexibility but leaves a room for a certain class of bugs. What I find interesting is that the answer to the question whether TypeScript, Flow or any other type system adopts structural or nominal type system does not have to be binary. So, is it possible to have the best of both worlds writing in TypeScript?

Nominal types allow for expressing problem semantics in a way that correctness of the program, up to some point, can be assured by a type checker. Both TypeScript and Flow type systems are mostly structural. Flow also adopts a few attributes of nominal type system but let’s leave it for now. In structural type system, two different types but of the same shape are compatible. In TypeScript, there are a few exceptions like in case of private properties. Later I present how to make a practical use of this feature.

Ryan Cavanaugh gathered a compelling list of use cases where nominal typing excels.

Before we dive into TypeScript, it is worth mentioning that Flow addresses this very problem with opaque types. When an opaque type is imported it hides its underlying type. Opaque type resembles a nominal type.

Table of contents

Use case

I would like to show you a few approaches of nominal typing wannabe implementations. Each is slightly different but I would recommend sticking to only one across your code base. To make things simpler, a goal of each implementation is to disallow to sum two numbers if both are not in USD.

Approach #1: Class with a private property

class USD {
  private __nominal: void;
  constructor(public value: number) {};
}

class EUR {
  private __nominal: void;
  constructor(public value: number) {};
}

const usd = new USD(10);
const eur = new EUR(10);

function gross(net: USD, tax: USD) {
  return { value: net.value + tax.value } as USD;
}

gross(usd, usd); // ok
gross(eur, usd); // Error: Types have separate declarations of a private property '__nominal'.

The main difference of this approach from any following is that it does not require to perform a type assertion (or casting if you wish). Types of usd and eur variables can be correctly inferred as we only create an instance of a new class. They are not compatible due to separate declarations of a private property. On the other hand, the main disadvantage is that class is a redundant construct from a purely logical standpoint.

Approach #2: Brands

interface USD {
  _usdBrand: void;
  value: number;
}

interface EUR {
  _eurBrand: void;
  value: number;
}

let usd: USD = { value: 10 } as USD;
let eur: EUR = { value: 10 } as EUR;

function gross(net: USD, tax: USD) {
  return { value: net.value + tax.value } as USD;
}

gross(usd, usd); // ok
gross(eur, usd); // Error: Property '_usdBrand' is missing in type 'EUR'.

As long as interfaces have different properties they are incompatible. TypeScript team follows this convention. We never assign values to a brand property so there is no runtime cost. There are cases in which interface can be an overkill but its the simplest way to have a taste of nominal typing in TypeScript I am aware of.

Approach #3: Intersection types

class Currency<T extends string> {
  private as: T;
}

type USD = number & Currency<"USD">
type EUR = number & Currency<"EUR">

const usd = 10 as USD;
const eur = 10 as EUR;

function gross(net: USD, tax: USD) {
  return (net + tax) as USD;
}

gross(usd, usd); // ok
gross(eur, usd); // Error: Type '"EUR"' is not assignable to type '"USD"'.

Here we take an advantage of intersection types. Both USD and EUR types have features of both number and Currency<T>. We never actually assign a value to the as property, it does not exist in runtime and class Currency itself will be defined as an empty class.

Although Currency could have a more abstract and generic name I would avoid generalization. It can easily go out of control in a real life project if being followed as mantra.

function ofUSD(value: number) {
  return value as USD;
}

function ofEUR(value: number) {
  return value as EUR;
}

const usd = ofUSD(10);
const eur = ofEUR(10);

function gross(net: USD, tax: USD) {
  return ofUSD(net + tax);
}

It is convenient to create a separate function to avoid a fuss with explicit type assertion.

Approach #4: Intersection types and brands

type Brand<K, T> = K & { __brand: T }

type USD = Brand<number, "USD">
type EUR = Brand<number, "EUR">

const usd = 10 as USD;
const eur = 10 as EUR;

function gross(net: USD, tax: USD): USD {
  return (net + tax) as USD;
}

gross(usd, usd); // ok
gross(eur, usd); // Type '"EUR"' is not assignable to type '"USD"'.

This approach is a mix of two previous one. Despite being a little hacky I find it the most elegant and clean solution. An error message is still descriptive. Moreover, type Brand is only a type and will not be present in the output code.

More reading

Photo by Thao Le Hoang on Unsplash.