Conditional types in TypeScript - Artsy Engineering

 
This year TypeScript gained a new feature that punches far above its weight.
Working through our (enormous) backlog of unsorted TypeScript "Suggestions" and it's remarkable how many of them are solved by conditional types.
Conditional types probably aren't something you'll write every day, but you might end up using them indirectly all the time. That's because they're great for 'plumbing' or 'framework' code, for dealing with API boundaries and other behind-the-scenes kinda stuff. So, dear reader, read on! It's always good to learn how the sausage is made. Then you can make sausage of your own.
Typewurst! 🌭
Note: This is a straightforward adaptation of a 35-minute presentation given at Futurice London's TypeScript Night meetup, and therefore provides more context than an ordinary blog post might. I hope a lot of that context is interesting and useful even for seasoned TypeScript developers. If you'd prefer a no-frills experience, check out the TypeScript 2.8 Release notes .

Your first conditional type

Here's some plain JavaScript
Reading the code, it's clear to a human that the .toUpperCase() method call is safe. We can tell that whenever a string is passed in to process, a string will be returned.
But notice that we could also pass something like null into the function, in which case null would be returned. Then calling .toUpperCase() on the result would be an error.
Let's add basic types to this function so we can let TypeScript worry about whether we are using it safely or not.
Seems sensible. What happens if we try to use it like before?
TypeScript complains because it thinks that the result of process("foo") might be null, even though we clever humans know that it won't be. It can't figure out the runtime semantics of the function on its own.
One way of helping TS understand the function better is to use 'overloading'. Overloading involves providing multiple type signatures for a single function, and letting TypeScript figure out which one to use in any given context.
Here we've said that if we pass a string, it returns a string, and if we pass null, it returns null. (The any type is ignored but still needs to be there for some reason 🤷‍️)
That works nicely:
But there's another use case that doesn't work:
TypeScript won't let us pass something that is of type string | null because it's not smart enough to collapse the overloaded signatures when that's possible. So we can either add yet another overload signature for the string | null case, or we can be like (╯°□°)╯︵ ┻━┻ and switch to using conditional types.
Here we've introduced a type variable T for the text parameter. We can then use T as part of a conditional return type: T extends string ? string : null. You probably noticed that this looks just like a ternary expression! Indeed, it's doing the same kind of thing, but within the type system at compile time.
And that takes care of all our use cases:
So that's what a conditional type is! A kind of ternary type expression. It always has this form:
A, B, C, and D can be any old type expressions, but all the important stuff is happening on the left there. In the A extends B condition.

Assignability

This extends keyword is the heart of a conditional type. A extends B means precisely that any value of type A can safely be assigned to a variable of type B. In type system jargon we can say that "A is assignable to B".
TypeScript decides which types are assignable to each other using an approach called 'structural typing'. This kind of type system started appearing in mainstream languages relatively recently (in the last 10 years or so), and might be a little counterintuitive if you come from a Java or C# background.
You may have heard of 'duck typing' in relation to dynamically-typed languages. The phrase 'duck typing' comes from the proverb
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
In duck typing, you judge a thing by how it behaves, rather than what it is called or who its parents are. It's a kind of meritocracy. Structural typing is a way of applying that same idea to a static compile-time type system.
So TypeScript only cares about what types can do, not what they are called or where they exist in a type hierarchy.
Take this simple example:
TypeScript is happy treating two completely unrelated classes as equivalent because they have the same structure and the same capabilities. Meanwhile, when checking the types at runtime, we discover that they are actually not equivalent.
This is a notable example of where the semantics of TypeScript are at odds with JavaScript. It might seem like a problem, but in practice structural typing is a lot more flexible than Java-esque 'nominal' typing, where names and hierarchy matter. The two aren't mutually exclusive, however. Some languages, like Scala and Flow, allow you to mix and match to suit particular problems.
Aside from that, the way that assignability works with structural typing is very intuitive.
Speaking structurally we can say that A extends B is a lot like 'A is a superset of B', or, to be more verbose, 'A has all of B's properties, and maybe some more'.
There's one minor caveat though, and that's with 'literal' types. In TypeScript you can use literal values of primitive types as types themselves.
The string "banana" doesn't have more properties than any other string. But the type "banana" is still more specific than the type string.
So another way to think of A extends B is like 'A is a possibly-more-specific version of B'.
Which brings us to 'top' and 'bottom' types: the least and most specific types, respectively.
In type theory a 'top' type is one which all other types are assignable to. It is the type you use to say "I have absolutely no information about what this value is". Think of it as the union of all possible types:
TypeScript has two top types: any and unknown.
  • Using any is like saying "I have no idea what this value looks like. So, TypeScript, please assume I'm using it correctly, and don't complain if anything I do seems dangerous".
  • Using unknown is like saying "I have no idea what this value looks like. So, TypeScript, please make sure I check what it is capable of at run time."
A 'bottom' type is one which no other types are assignable to, and that no values can be an instance of. Think of it as the empty union type:
TypeScript has one bottom type: never. That's a nice descriptive name because it literally means this can never happen.
Top and bottom types are useful to know about when working with conditional types. never is especially useful when using conditional types to refine unions...

Refining unions with distributive conditional types

Conditional types let you filter out particular members of a union type. To illustrate, let's say we have a union type called Animal:
And imagine that we needed to write a function that used only those animals which are also cats. We might write some helper type called ExtractCat to do that:
I know lions and tigers don't meow, but how cute would it be if they did ^_^
This seemed vague and magical to me at first. Let's see what TypeScript is doing under the hood when it evaluates ExtractCat<Animal>.
First, it applies ExtractCat recursively to all the members of Animal:
Then it evaluates the conditional types:
And then something fun happens... Remember that no values can ever be of type never? That makes it totally meaningless to include never in a union type, so TypeScript just gets rid of it.
The TypeScript jargon for this kind of conditional type is distributive conditional type.
That 'distribution', where the union is unrolled recursively, only happens when the thing on the left of the extends keyword is a plain type variable. We'll see what that means and how to work around it in the next section.

A real use-case for distributive conditional types.

A while ago I was building a Chrome extension. It had a 'background' script and a 'view' script that ran in different execution contexts. They needed to communicate and share state, and the only way to do that is via serializable message passing. I took inspiration from Redux and defined a global union of interfaces called Action to model the messages that I wanted to be able to pass between the contexts.
And then there was a global dispatch function that I could use directly to broadcast messages across contexts
This API is typesafe and it plays well with my IDE's autocomplete and I could have left it there. I could have moved on to other things.
But there's this little voice inside my head. I think most developers have this voice.
INT. HIPSTER CO-WORKING SPACE - DAY DAVID sits on an oddly-shaped orange chair. His MacBook rests askew on a lumpy reclaimed wood desk. He stares at colorful text on a dark screen. A tiny whisper. VOICE (V.O.) Psst! David looks around for a moment and then stares back at the laptop. VOICE (V.O.) Psst! Hey! Startled this time, David looks around again. He speaks to nobody in particular. DAVID Is someone there? VOICE (V.O.) It's me, the DRY devil. David heaves a painful sigh of recognition. DAVID Not you again! Leave me alone! DRY DEVIL (V.O.) DRY stands for "Don't Repeat Yourself" DAVID I know, you say that every time! Now get lost! DRY DEVIL (V.O.) I've noticed an issue with your code. DAVID Seriously, go away! I'm busy solving user problems to create business value. DRY DEVIL (V.O.) Every time you call `dispatch` you are typing 6 redundant characters. DAVID Oh snap! You're right! I must fix this. MONTAGE David spends the next 2 hours wrestling with TypeScript, accumulating a pile of empty coffee cups and protein ball wrappers.
We've all been there.
I wanted the dispatch function to work like this:
Deriving the type for that first argument is easy enough.
But the type of the second argument depends on the first argument. We can use a type variable to model that dependency.
Woah woah woah, what's this ExtractActionParameters voodoo?
It's a conditional type of course! Here's a first attempt at implementing it:
This is a lot like the ExtractCat example from before, where we were were refining the Animals union by searching for something that can meow(). Here, we're refining the Action union type by searching for an interface with a particular type property. Let's see if it works:
Almost there! We don't want to keep the type field after extraction because then we would still have to specify it when calling dispatch. And that would somewhat defeat the purpose of this entire exercise.
We can omit the type field by combining a mapped type with a conditional type and the keyof operator.
A mapped type lets you create a new interface by 'mapping' over a union of keys. You can get a union of keys from an existing interface by using the keyof operator. And finally, you can remove things from a union using a conditional type. Here's how they play together (with some inline test cases for illustration):
Then we can use ExcludeTypeField to redefine ExtractActionParameters.
And now the new version of dipsatch is typesafe!
But there's one more very serious problem to address: If the action has no extra parameters, I still have to pass a second empty argument.
That's four whole wasted characters! Cancel my meetings and tell my partner not to wait up tonight! We need to fix. this.
The naïve thing to do would be to make the second argument optional. That would be unsafe because, e.g. it would allow us to dispatch a "LOG_IN" action without specifying an emailAddress.
Instead, let's overload the dispatch function.
How can we define this ExtractSimpleAction conditional type? We know that if we remove the type field from an action and the result is an empty interface, then that is a simple action. So something like this might work
Except that doesn't work. ExcludeTypeField<A> extends {} is always going to be true, because {} is like a top type for interfaces. Pretty much everything is more specific than {}.
We need to swap the arguments around:
Now if ExcludeTypeField<A> is empty, the condition will be true, otherwise it will be false.
But this still doesn't work! On-the-ball readers might remember this:
That 'distribution', where the union is unrolled recursively, only happens when the thing on the left of the extends keyword is a plain type variable. We'll see what that means and how to work around it in the next section.
  • - Me, in the previous section
Type variables are always defined in a generic parameter list, delimited by < and >. e.g.
And if you want a conditional type to distribute over a union, the union a) needs to have been bound to a type variable, and b) that variable needs to appear alone to the left of the extends keyword.
e.g. this is a distributive conditional type:
and these are not:
When I discovered this limitation I thought that it exposed a fundamental shortcoming in the way distributive conditional types work under the hood. I thought it might be some kind of concession to algorithmic complexity. I thought that my use case was too advanced, and that TypeScript had just thrown its hands up in the air and said, "Sorry mate, you're on your own".
But it turns out I was wrong. It is just a pragmatic language design decision to avoid extra syntax, and you can work around it easily:
All we did is wrap the meat of our logic in a flimsy tortilla of inevitability, since the outer condition A extends any will, of course, always be true.
And finally we can delete those four characters 🎉🕺🏼💃🏽🎈
That's one yak successfully shaved ✔
TypeScript provides a couple of built-in types that we could have used in this section:
e.g. instead of defining ExcludeTypeField like this:
we could have done this:
And instead of defining ExtractActionParameters like this:
we could have done this:

💡 Exercise for the intrepid reader

Notice that this still works.
Use what you've learned so far to make it an error to supply a second argument for 'simple' actions.

Destructuring types with infer

Conditional types have another trick up their sleeve: the infer keyword. It can be used anywhere in the type expression to the right of the extends keyword. It gives a name to whichever type would appear in that place. e.g.
It handles ambiguity gracefully:
You can even use infer multiple times.

Other built-in conditional types

We've already seen Exclude and Extract, and TypeScript provides a few other conditional types out of the box.

Further reading