This TypeScript type haunted my dreams

We all dream about language features, right?

After a year of being on sabbatical (that’s fancy-talk for unemployed), I recently joined Salto’s engineering team and started learning TypeScript. Salto allows business application admins to use DevOps methodlogies when managing their configuration. One of the cool things about them is that their core business logic is open source. As part of that OSS repo, there’s a standalone library called lowerdash, where I found they have a nifty little type called OneOf to express mutually-exclusive properties. Here’s an example:

type TreeNode = OneOf<{
    value: number,
    children: TreeNode[]
}>

OneOf works such that TreeNode must have exactly one of the listed properties. It can have either value or children, but not both and not none. There’s also an option to specify specific properties for the OneOf, like so:

type TreeNode = OneOf<{
    name: string
    value: number
    children: TreeNode[]
}, "value" | "children">

I was interested to know how this OneOf type is implemented. And I was quite impressed that it’s only two lines long:

export type AllowOnly<T, K extends keyof T> = Pick<T, K> & { [P in keyof Omit<T, K>]?: never }  
export type OneOf<T, K = keyof T> = K extends keyof T ? AllowOnly<T, K> : never  

I tried making sense of it, but simply couldn’t. It took me a few hours and a bunch of documentation lookups to figure out all the typing features used here, and I’ll share what I discovered with you here.

AllowOnly

Let’s figure out AllowOnly, our “helper” type. It’s a generic class that works on two types - T which is just any arbitrary type, and K which extends keyof T. Let’s unpack this.

keyof is called the Index Type Query Operator. It’s a type operator that resolves to a Union construct of all the keys in a given type. Most types have string keys1, so given the following type:

type User = {
    name: string
    age: number
}

What does it mean to extend a keyof T type, though? If keyof T is a union of all key literals (key names) in T, then extends keyof T is a union of any subset of keys in T. In our example, either type 'name' or 'age' are considered as extending keyof T. 'name' | 'age' would also be considered as extending itself.

In short, AllowOnly<T, K extends keyof T> accepts an arbitrary type and a subset of keys from that type.

Let’s look at the actual type implementation:

export type AllowOnly<T, K extends keyof T> = Pick<T, K> & { [P in keyof Omit<T, K>]?: never }  

Remember when I said the implementation was just these two lines? Well actually I lied, because it uses both Pick and Omit which are built-in generic types, but they are interesting to understand here, so let’s take a little detour and explore their implementation as well.

Pick and Omit

Pick and Omit are two built-in generic types that help create “subtypes” from given types.

Pick<T, K> creates a type similar to T, but only with keys specified in K. Omit<T, K> does the reverse - it creates a type similar to T but only with keys not specified in K.

Here’s an example:

type Person {
    firstName: string
    lastName: string
    age: number
} 

type OnlyLastName = Pick<Person, 'lastName'>
// is equivalent to:
type OnlyLastName = Omit<Person, 'firstName' | 'age'>

Here’s a possible implementation of Pick:

type Pick<T, K extends keyof T> = {
    [Key in K]: T[Key]
}

A lot to unpack here. We already understand the signature, so let’s break down the implementation one-liner. First, we define a type with this weird square brackets definition. Let’s look at a simpler case and then work our way back here.

type StudentGrades = {
    [studentName: string]: number
}

This type means that we can assign and access arbitrary string properties, and they all map to number values. For example:

let grades: StudentGrades = {
    'Amir': 100,
    'Ram': 80
}

grades.Amir === 100
grades["Amir"] === 100
grades.willThisWork === undefined

This way of specifying types is called an Index Signature.

Did you notice that we didn’t actually use the property name studentName from the type definition? It actually doesn’t matter what property name we pick, so it’s probably best to use something descriptive for readability.

There are more details to index signatures: you can use number indices, as well as including other properties except for the indexed ones. Since these aren’t used in the OneOf implementation, we’ll skip those.

Index signatures are great, but there’s something extra weird happening in our Pick implementation, because it uses in and has even more weirdly placed square brackets.

First, Key in K will loop over all the keys in type K and will map each of them to a new type depending on what’s on the right side of the colon2. Using this syntax makes this type a Mapped Type because we map property names from one type to new types.

Second, we have T[Key]. This looks like accessing an array by index, but for… types? It returns the type of the property Key in K and this language feature is understandably called Indexed Access Types.

Let’s look at the Pick implementation again, now that we understand all the moving parts:

type Pick<T, K extends keyof T> = {
    [Key in K]: T[Key]
}

We can now finally understand Pick: it takes a type and a bunch of keys from that type, and it maps those keys (and only those keys) to the same type they had originally. It creates a new type that has only some of the fields from the original T.

Alright, we know how Pick works, and Omit is Pick’s twin sister which does basically the opposite, so I don’t think we’ll need any new language feat–

type Omit<T,K extends keyof T> = {
   [Key in keyof T as Key extends K ? never : Key] : T[Key]
}

I swear this isn’t a random collection of keywords here. I hate to say it again, but I will: let’s unpack this.

We have a ternary operator here, which is just a regular ternary operator we know and love from “regular code”. No reason to suspect that anything fishy is going on with ternary operators, right?

Let’s examine the predicate here. Key in keyof T as Key extends K. The main operator here is extends. It checks whether the left side extends the right. On the left we have Key in keyof T as Key. Let’s put as Key aside for now, and we can tell that this basically means “loop over all the keys in T”. Note that we are looking at all the keys in T, not just the subset provided by K. We then check, using extends, whether each key is also a subset of K. If it is, the ternary operator returns never, otherwise it returns Key.

never and Key are both types. For each property in T, if it’s in K, we mark it as never, essentially removing it from the mapped type. If the key is not in K, we keep it as is, and map it to its original type. We created a new type from T, where we drop all of the keys from K and keep the rest with their original type.

Using conditionals in type definitions is a language feature aptly called Conditional Types.

We can finally move up the DFS chain and go back to looking at our actual code.

AllowOnly, but this time we’re serious!

export type AllowOnly<T, K extends keyof T> = Pick<T, K> & { [P in keyof Omit<T, K>]?: never }  

AllowOnly accepts a type and a subset of its keys and creates a new type that allows only those keys to be set. Any other keys from T will be forbidden from this new type. It’s sort of a “stricter” Pick.

The implementation of AllowOnly is an Intersection (the & operator) of two types. An intersection means that it is the merging of the two intersected types. On the left hand side we have a simple Pick<T, K>. This is the easy part. The right hand side is more magic:

{
    [P in keyof Omit<T, K>]?: never
}

Luckily, we already know how to read this. Omit<T, K> will have all the keys in T that are not in K, we loop over these and mark them as never, disallowing them.

When we intersect the two sides, we get a new type that is like T that has all properties K from T, while forbidding any property not in K. It’s T, but we AllowOnly the K keys here.

Finally, finally, we can look at OneOf.

The boss fight: OneOf

export type OneOf<T, K = keyof T> = K extends keyof T ? AllowOnly<T, K> : never  

Right off the bat, we notice that K isn’t defined as K extends keyof T, but rather defaults to being every key in T. Looking at the implementation, we see the weirdest ternary operator ever.

This is the part that took me hours to research and figure out, because it seems like it’s sort of not doing anything?

The predicate is K extends keyof T. Let’s assume for a moment the K is used with its default value, which is keyof T. Then surely K extends keyof T would return true, right? And then the returned type will be AllowOnly<T, keyof T> which is… T? What the hell is going on??

Turns out, when you use generic types with conditional typing (checking extends with the ternary operators) and the type you’re checking is a union type (like we have here - a union of keys), then the conditional typing is preformed on each type in the union, individually. Then, the results are again put into a union. This is logically a map operation!

This language feature is called Distributive Conditional Types.

We are basically doing the following (which is just pseudo code, not valid syntax):

Union(K.map(Key => Key extends keyof T ? AllowOnly<T, K> : never))

To put it all together, let’s look at an example instantiation of OneOf (the same from above) and look at the resulting type:

type TreeNode = OneOf<{
    value: number,
    children: TreeNode[]
}>

In this case, the type of TreeNode would be:

AllowOnly<TreeNode, 'value'> | AllowOnly<TreeNode, 'children'>

Which is TreeNode where either only value is allowed, or only children is allowed. It’s a OneOf!

Sources

  1. Types can also use integer keys, in which case keyof would return number. See the official docs for more info. ↩︎

  2. Key is a generic identifier, not a keyword. You could use P in K or whatever you want. ↩︎

Discuss this post at the comment section below.
Follow me on Twitter and Facebook
Thanks to Yonatan Nakar, Sion Schori, Amir Taboul and Ori Moisis for reading drafts of this.

Similar Posts