Flavoring: Flexible Nominal Typing for TypeScript

Recently, we’ve been making heavy use of TypeScript at Atomic Object. We love the great tooling and instant feedback we get with the language’s powerful type system. TypeScript’s structural type system gives us a lot of powerful tools for making invalid states unrepresentable, thereby pointing out bugs at compile time instead of runtime.

However, one challenge we’ve faced with TypeScript in applying this approach is how to differentiate between values that have the same shape, but mean very different things. For example, if both my Person and my BlogPost have a numeric ID, I’d really like to communicate to TypeScript that they’re not interchangeable. But a function that takes a number accepts both kinds of values.

This post will examine the challenges one might face using the usual approach to this modeling problem, and how we’ve side-stepped them with a variation on the usual technique.

The Usual Approach: Branding

One solution to this problem is to use an approximation of nominal typing. We can use tricks in TypeScript to force the compiler to treat these values as incompatible. This is often called “branding” a type to make it incompatible with other values of the same runtime shape–we use this for statically typing ISO8601 strings, for example.

We could use this technique to make a Person ID and a BlogPost ID incompatible:

interface PersonIdBrand { _type: "Person"; }
type PersonId = PersonIdBrand & number;

interface BlogPostIdBrand { _type: "BlogPost"; }
type BlogPostId = number & BlogPostIdBrand;


With these types, TypeScript will catch places where we mix up Person and BlogPost IDs and warn us in our editor:

const pid: PersonId = person.id; // OK
let bpid: BlogPostId = person.id; // Doesn't type check!


While both pid and bpid are numbers at runtime, TypeScript distinguishes between them and treats them as incompatible.

The Problem with Branded Types

We were initially quite excited to use this technique to enrich our data models to understand exactly what a function is intended to do and to help catch common bugs. However, we quickly ran into a problem: branding is very strict. In addition to disallowing mixing of different branded types, it will not let you use an unbranded number in a branded context. For example:

const pid: PersonId = 1; // Error! 

This caused all sorts of problems for us. A function which takes a PersonId couldn’t take a known good number (e.g. in a unit test), for example. But where branding really broke down for us was in our use of code generators.

Since TypeScript doesn’t have a reflection system, it’s not uncommon to use code generators to generate TypeScript types from other ways of describing shapes. We’re using Apollo Codegen to generate TypeScript types from GraphQL queries, for example.

This allows us to write a GraphQL query using our TypeScript starter kit, such as:

query AllPeopleNames {
	allPeople {
		id
		name
	}
}


and automatically get a TypeScript type to help us consume the resulting data:

export type AllPeopleNamesQuery = {
	allPeople: Array<{
		id: number;
		name: string;
	}>;
}

This usually works great, as TypeScript will guarantee this shape is structurally compatible with any use of the data. But here’s the problem: apollo-codegen doesn’t know about our branded types, and it doesn’t use them. Because branding is a strict form of nominal typing, the resulting structure is incompatible with any other type that uses our branded types. We can’t, for example, pass one of the objects in our allPeople results to a context expecting a Person type that uses the branded id:

const person: Person = allPeople[0];
// Error: number is not assignable to PersonId!


We had to either map over the array or cast the entire composite structure–a choice between inconvenience and complete lack of safety.

Flavor: Branded Types Supporting Implicit Conversion

Eventually, we hit upon a solution: a variant of branding that allows unbranded values to be implicitly converted into the branded type, but doesn’t allow implicit conversion between branded types. We call this Flavor instead of branding, to distinguish the techniques.

The definition of Flavor is very simple:

interface Flavoring<FlavorT> {
  _type?: FlavorT;
}
export type Flavor<T, FlavorT> = T & Flavoring<FlavorT>;


We can then use Flavor to define new ID types:

type PersonId = Flavor<number, “Person”>
type BlogPostId = Flavor<number, “BlogPost”>


Because the _type property of Flavoring is optional, implicit conversion is supported:

const personId : PersonId = 1; // OK
const person: Person = personLikeStructure // OK

but we get the safety of branding in that incompatible types don’t mix:

const blogPostId : BlogPostId = personId; // Error!

TypeScript won’t let us mix our ID types because, under the hood, the _type properties are incompatible. The optional string "Person" is not assignable to the optional string "BlogPost". But because the _type is optional, implicit conversion from unflavored values of the same underlying type is allowed. Once implicit conversion happens, however, TypeScript will henceforth treat the value as potentially having the declared _type and disallow mixing with other, differently flavored types. Thus, downstream consumers of the value will get the safety and semantic benefits.

This ends up being super-convenient, and liberal use of Flavor enriches your domain and helps catch many simple mistakes without introducing what could otherwise be too much friction. We use it for database IDs, units of measure (e.g. milliseconds vs. seconds), and more.

Flavoring vs. Branding

Both techniques are useful, but the Flavor variant of branding is more broadly applicable in our experience. It works well when:

  • You want to allow implicit conversion of composite-structures which are from trusted sources, but want to use semantic subtypes for e.g. IDs to get type system support in downstream code (e.g. our GraphQL query example).
  • You wish to trace a category or source of a simple value, but aren’t willing to sign up for the friction of casting or using functions to “bless” values explicitly in all of your unit tests, etc. Units of measure are a good example.
  • You want to annotate the type of an argument with semantic information in a way that TypeScript can trace for you and make visible in e.g. editor tooltips while still using simple types at runtime.

Branding also has its uses. We still use the stricter approach when:

  • We want to write code that can safely assume some upstream validation has occurred–e.g. a DateStr which must be in a valid ISO8601 format.
  • A type error admitted by implicit conversion could lead to a dangerous error, such as when using types to access tokens to model authorization permissions.