Branded Types

April 27, 2024 (2mo ago)

Archive

Not all TypeScript code is created equal in terms of safety or quality. There are many ways to enhance the safety of your TS code, and one of the ways is using a pattern called "Branded Types," also known as "Nominal Types", "Unique Types," or simply "New Types." They all mean the same thing, which is to establish deeper specificity and uniqueness for modeling your data beyond basic type primitives.

Problem

So here's a question for you. Can you figure out how many things that can go wrong here?

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

What does requestBaz() return exactly? Suppose it does, Is it a string? If so, would any string do?

fooID and barID are both strings so if you mistakenly mix both parameter of the requestBaz() function as such

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

The code will run, but the logic breaks and the bug goes totally undetected. This gets amplified when the codebase has tens or hundreds of thousands of lines.

As you can see, there are so many ways this can go south. So here's how to fix it with new types.

Solution

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

Note: New types must be unique to get types violations flagged by linter. So this type definition:

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

Will not help detect any errors. Even though the type name declaration itself is different as in FooID and BarID, they're actually both of type BarID.

You can now use these types to annotate the IDs for Foo and Bar

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

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

And if you're wondering how to create the NewType yourself here's the type definition:

declare const __s: unique symbol;
export type NewType<N, T> = T & {
  /**
   * Property `__s` is not intended for direct access nor modification.
   * @internal
   */ [__s]: N;
};

We also need the return type for the function, it is a string, true, but what type of string exactly? Can any string do?

So we need to create a new unique type called Baz for the type of function return.

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

One more thing though, the function requestBaz(), may or may not find a Baz. Should it fail fast and error out? Or fail safe? I think failing safe is better here, maybe we need a type that returns Baz if found, else null, not void nor undefined since these do not require explicit return, which we do need here. Perhaps we need some optional type like

type Optional<T> = T | null;

Now, using all of these here's the the same piece of code, enhanced and bug free.

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> {
19  // String methods work for fooID and barID, since they're both strings.
20  if (
21    fooID.concat().toLowerCase() === 'fooid' &&
22    barID.concat().toLowerCase() === 'barid'
23  ) {
24    return 'baz' as Baz; 
25  }
26    return null; // You have to explicitly return null here.
27}
28const foo = {} as Foo;
29const bar = {} as Bar;
30
31// The line below will work just fine.
32const baz1 = requestBaz(bar.id, foo.id); 
33
34
35// But this will fail.
36const baz2 = requestBaz(foo.id, bar.id); 
37/* TypeError: 
38    Argument of type 'FooID' is not assignable to parameter of type 'BarID'.
39    Type 'FooID' is not assignable to type '"BarID"' 
40  */

Oh btw, ts-roids is a library I created this week, the goal is to bulletproof TypeScript with types and decorators, it includes more than 120+ utilities at the time of writing, you can check it out here