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↗.