Branded Types

April 27, 2024 (1y ago)

Archive

Here’s a simple question for you: how many things can go wrong in this code?

1function requestBaz(barID: string, fooID: string) {
2  if (
3    fooID.concat().toLowerCase() === 'fooid' &&
4    barID.concat().toLowerCase() === 'barid'
5  ) {
6    return 'baz';
7  }
8}
9
10type Foo = {
11  id: string;
12  foo: string;
13};
14
15type Bar = {
16  id: string;
17  bar: string;
18};
19
20const baz1 = requestBaz(foo.id, bar.id);
21const baz2 = requestBaz(bar.id, foo.id);
22

What does requestBaz() actually return? Is it a string? If so, can any string work? Or only some? Let’s talk about the real issue here: fooID and barID are both plain strings. Mix them up, like this:

const baz1 = requestBaz(foo.id, bar.id);
const baz2 = requestBaz(bar.id, foo.id);

This code runs. No errors. But the logic is broken, and the bug goes undetected

Of course, you could use objects like this:

const foo = { id: 'fooId', foo: 'some value' };
const bar = { id: 'barId', bar: 'another value' };

But you're still going to run into the same problem! Interchanging foo.id with bar.id will break your logic, and TypeScript won’t catch it. Branded types solve that by ensuring these are distinct types, making sure your code stays safe.

With Branded Types, we create unique types for FooID and BarID. They’re both strings under the hood, but they’re not interchangeable anymore. Here’s how you define them:

type FooID = NewType<'FooID', string>;
type BarID = NewType<'BarID', string>;

Note: The “branding” here needs to be unique, or the type checker won’t flag violations. If you do this:

type FooID = NewType<'BarID', string>;
type BarID = NewType<'BarID', string>;

The linter won’t help. Even though the type names (FooID and BarID) are different, they’re both internally just BarID. Congratulations, you’re back to square one. Now let’s put these types to use.

type Foo = {
  id: FooID;
  foo: string;
};

type Bar = {
  id: BarID;
  bar: string;
};

How to Create a NewType

Here’s the type definition for NewType:

declare const __s: unique symbol;
export type NewType<N, T> = T & {
  [__s]: N;
};

Now let’s talk about the return type of requestBaz(). Sure, it returns a string, but does any string work? No. It’s a specific kind of string, a Baz. So let’s create a branded type for it:

type Baz = NewType<'Baz', string>;

Also, requestBaz() might not find a Baz. Should it error out? Or fail safe? I think failing safe is better. Let’s create an optional type that explicitly returns Baz if found, or null if not:

type Optional<T> = T | null;

Here’s the same function, now bulletproof:

1import type { NewType, Optional } from 'ts-roids';
2
3type FooID = NewType<'FooID', string>;
4type BarID = NewType<'BarID', string>;
5
6type Foo = {
7  id: FooID;
8  foo: string;
9};
10
11type Bar = {
12  id: BarID;
13  bar: string;
14};
15
16type Baz = NewType<'Baz', string>;
17
18function requestBaz(barID: BarID, fooID: FooID): Optional<Baz> {
19if (
20  fooID.concat().toLowerCase() === 'fooid' &&
21  barID.concat().toLowerCase() === 'barid'
22) {
23  return 'baz' as Baz;
24}
25  return null; // Explicitly return null.
26}
27
28const foo = {} as Foo;
29const bar = {} as Bar;
30
31// The line below works perfectly.
32const baz1 = requestBaz(bar.id, foo.id);
33
34// This will fail with a clear type error.
35const baz2 = requestBaz(foo.id, bar.id);
36/_ TypeError:
37Argument of type 'FooID' is not assignable to parameter of type 'BarID'.
38Type 'FooID' is not assignable to type '"BarID"'.
39_/

By the way, the ts-roids library I created includes the NewType utility and more than 120+ type-safe utilities and @decorators to bulletproof your TypeScript code. Check it out here.

Subscribe to my newsletter. The extension of these thoughts and more.