07/23/2022

Unlocking type-safety superpowers in Typescript with nominal and refinement types


TLDR; I came across Typescript's lesser known symbols to fix a problem in our codebase, but then realized it could be extended to unlock even stronger type-safety guarantees

Origin story — Pernicious bugs

At modfy, we ran into a common source of bugs in our TS codebase. We have the types Media and MediaElement, which both have id: string properties. These two types were often used together, and it was surprisingly very easy to mistakenly give the media element's ID when the media's ID was expected and vice versa.

const fetchMediaById = (mediaId: string): Media => { /* ... */ } const mediaId: string = getMedia().id const mediaElementId: string = getMediaElement().id // It can be really easy to accidentally give the media element's ID when the media's ID was // expected because the types are the same fetchMediaById(mediaId)

This problem causes a pernicious kind of bug that passes the type system, but can wreak havoc if you're not careful.

Ideally we would like to force this problem into the jurisdiction of the type-system, so it can be caught at compile time.

Our idea is to mimic Rust/Haskell's newtype idiom. Our first attempt:

type MediaId = string type MediaElementId = string

Unfortunately, this doesn't work:

const doSomething = (mediaId: MediaId) => { /* ... */ } const ohno: MediaElementId = getRandomMediaElementId() doSomething(ohno)

The problem is that Typescript doesn't have nominal types. With nominal types, values are compatible if their types have the exact same name.

I told my friend @zhenghao about this problem and he mentioned that symbols might give us a solution... and they did!

Nominal types

After researching into Typescript's symbols, me and my co-founder @CryogenicPlanet came up with a really ergonomic solution:

import { Nominal, nominal } from 'nominal-type' type MediaId = Nominal<'MediaId', number>; type MediaElementId = Nominal<'MediaElementId', number>; const fetchMediaById = (mediaId: MediaId): Media => { /* ... */ } const randomMediaId: MediaId = getRandomMediaId() const randomMediaElementId: MediaElementId = getRandomMediaElementId() fetchMediaById(randomMediaId) // fails as you would expect! fetchMediaElementId(getRandomMediaElementId)

We were giddy with excitement as we converted our codebase to using nominal types. But then we realized this was just the beginning, we could extend this feature to gain even more type-safety in our codebase.

Unlocking even greater type-safety with refinement types

Nominal types give us a way preserve the "identity" of types in the type system, and we can take that one step further to "decorate" or "annotate" types with useful information.

Here's an example (try it out on Repl.it):

type SortedArray<T> = Nominal<'sortedArray', Array<T>> const sort = <T>(arr: Array<T>): SortedArray<T> => arr.sort() const binarySearch = <T>(sorted: SortedArray<T>, val: T): number | undefined => { /* do binary search here... */ } const regularArray = [1, 2, 3] // won't work binarySearch(regularArray, 44) const sortedArray = sort(regularArray) // will work binarySearch(sortedArray, 420)

An invariant of the binary search algorithm is that the input array is sorted in ascending order. We can "decorate" our array with a type that tells us that it is sorted.

This goes beyond just type-safety, it's a performance optimization: once you know the array is sorted, you never have to sort it again.

Think about all the gratuitous and redundant validation checks you've had to write, you can now encode that validation into a type and only ever have to do it once.

Examples include: checking a user exists in the DB, checking a string is a valid e-mail address, the list goes on.

Another example: composing nominal/refinement types

The next logical step is to see if we can get our nominal types to compose. Let's say I have an array sorted in ascending order, and I want to get the largest item in the array.

Without refinement types, I need to be extra careful that the array is sorted, and that it's not empty so I don't get an undefined result. There's no way to enforce that without refinement types, so it might result in a lot of redundant checks to see if the array is not empty and sorted.

const largestItem = <T>(arr: Array<T>): T | undefined => { if (arr.length === 0) { return undefined } if (!isSorted(arr)) { throw new Error('Array is not sorted') } return arr[arr.length - 1] }

But with our refinement types, we can compose our SortedArray<T> with a new NonEmptyArray<T> type and we can get rid of the redundant checks (try it out on Repl.it):

type SortedArray<T> = Nominal<'sortedArray', Array<T>> const sort = <T>(arr: Array<T>): SortedArray<T> => arr.sort() as SortedArray<T> const nonEmpty = <T>(arr: Array<T>): arr is NonEmptyArray<T> { if (arr.length === 0) { return false } return true } type NonEmptyArray<K, T extends Array<K>> = Nominal<'nonEmptyArray', T>; type NonEmptySorted<T> = NonEmptyArray<T, SortedArray<T>>; const lastItem = <T>(nonEmptySortedArr: NonEmptySorted<T>): T => { return nonEmptySortedArr[nonEmptySortedArr.length - 1] }

How is it done?

Remember when I said Typescript had no nominal types? I lied. Typescript's symbols give us a hack to get what we want.

Symbols are something you've probably never used and have only ever seen from the error message that goes like this: Type X must have a 'Symbol.iterator' method that returns an iterator., or at least that was how it was for me.

Thankfully, my friend @zhenghao pointed me in the direction by mentioning them, which led me to some research.

Now I can finally eradicate this class of bugs from our codebase! Here's an example of how it works:

declare const MediaIdSymbol: unique symbol declare const MediaElementIdSymbol: unique symbol export type MediaId = string & { readonly __id: typeof MediaIdSymbol } export type MediaElementId = string & { readonly __id: typeof MediaElementIdSymbol } export const newMediaId = (id: string) => id as MediaId export const newMediaElementId = (id: string) => id as MediaElementId const doSomething = (id: MediaId) => { /* ... */ } const mediaId: MediaId = getRandomMediaId() const mediaElementId: MediaElementId = getRandomMediaElementId() doSomething(mediaId) // Argument of type 'MediaElementId' is not assignable to parameter of type 'MediaId'. // Type 'MediaElementId' is not assignable to type '{ __id: unique symbol; }'. // Types of property '__id' are incompatible. // Type 'typeof MediaElementIdSymbol' is not assignable to type 'MediaIdSymbol'. doSomething(mediaElementId)

Don't believe me? See it in action for yourself.

Okay, what the hell is going on here? Allow me to walk you through it.

First up, we're using the unique symbol type. This type is special because: "each reference to a unique symbol implies a completely unique identity that’s tied to a given declaration." (TS docs).

Waaait, that sounds oddly like the definition of a nominal type.

Great, how do we use it?

The TS docs list three ways, and using declare const MySymbol is the easiest, so we're going to use that.

declare const MediaIdSymbol: unique symbol declare const MediaElementIdSymbol: unique symbol // Remember: // typeof MediaIdSymbol !== typeof MediaElementIdSymbol

Then we can use intersection type operator (&) to merge it with whatever type we want to make nominal. On the left-hand side we use string, and on the right hand side we have our symbol property: { readonly __id: typeof MediaIdSymbol }.

We don't want anyone touching this symbol property so we make it readonly and give the name underscores to signal that.

We also don't want to bear a runtime cost for something we will only use at compile-time. We can use type casting to forego having to instantiate the symbol property: id as MediaId.

// Merge the type with the symbol type to make it nominal export type MediaId = string & { readonly __id: typeof MediaIdSymbol } export type MediaElementId = string & { readonly __id: typeof MediaElementIdSymbol } // Use type casting as a hack around having // to instantiate the symbol property export const newMediaId = (id: string) => id as MediaId export const newMediaElementId = (id: string) => id as MediaElementId

And there we have it, that's a basic rundown of how it works!

Conclusion

With this new pattern of using nominal types to enforce type-safety, you can now write code that is more robust and performant. We've already converted a large portion of our codebase to using nominal types at modfy and it's definitely resulted in less bugs.

Let me know if you have any questions/comments on my Twitter.

Further reading

A lot of the work here was inspired by these resources:


Got any questions? Did I say something completely bogus and wrong? Hit me up on Twitter.