Conditional types in TypeScript

By David Sheldrick

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.

-- Ryan Cavanaugh, TypeScript maintainer

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

1
2
3
4
5
function process(text) {
  return text && text.replace(/f/g, "p")
}

process("foo").toUpperCase()

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.

1
2
3
function process(text: string | null): string | null {
  return text && text.replace(/f/g, "p")
}

Seems sensible. What happens if we try to use it like before?

1
2
//            βŒ„ Type Error! :(
process("foo").toUpperCase()

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.

1
2
3
4
5
function process(text: null): null;
function process(text: string): string;
function process(text: any) {
  ...
}

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:

1
2
3
4
// All clear!
process("foo").toUpperCase()
//           βŒ„ Type Error! :)
process(null).toUpperCase()

But there's another use case that doesn't work:

1
2
3
4
declare const maybeFoo: string | null

//      βŒ„ Type Error! :(
process(maybeFoo)

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.

1
2
3
4
5
function process<T extends string | null>(
  text: T
): T extends string ? string : null {
  ...
}

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:

1
2
3
typeof process("foo") // => string
typeof process(null) // => null
typeof process(maybeFoo) // => string | null

So that's what a conditional type is! A kind of ternary type expression. It always has this form:

1
A extends B ? C : D

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

1
2
3
declare const a: A
const b: B = a
// type check succeeds only if 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:

1
2
3
4
5
6
7
class A {}
class B {}

const b: B = new A() // βœ” all good
const a: A = new B() // βœ” all good

new A() instanceof B // => false

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Shape {
  color: string
}

class Circle {
  color: string
  radius: number
}

// βœ” All good! Circles have a color
const shape: Shape = new Circle()
// ✘ Type error! Not all shapes have a radius!
const circle: Circle = shape

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.

1
2
3
4
let fruit: "banana" = "banana"

// Type Error! "apple" is not assignable to "banana"
fruit = "apple"

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:

1
type Top = string | number | {foo: Bar} | Baz[] | ... | ∞

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:

1
type Bottom = βˆ…

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:

1
type Animal = Lion | Zebra | Tiger | Shark

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:

1
2
3
4
type ExtractCat<A> = A extends { meow(): void } ? A : never

type Cat = ExtractCat<Animal>
// => Lion | Tiger

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:

1
2
3
4
5
type Cat =
  | ExtractCat<Lion>
  | ExtractCat<Zebra>
  | ExtractCat<Tiger>
  | ExtractCat<Shark>

Then it evaluates the conditional types:

1
type Cat = Lion | never | Tiger | never

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.

1
type Cat = Lion | Tiger

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Action =
  | {
      type: "INIT"
    }
  | {
      type: "SYNC"
    }
  | {
      type: "LOG_IN"
      emailAddress: string
    }
  | {
      type: "LOG_IN_SUCCESS"
      accessToken: string
    }
// ...

And then there was a global dispatch function that I could use directly to broadcast messages across contexts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
declare function dispatch(action: Action): void

// ...

dispatch({
  type: "INIT"
})

// ...

dispatch({
  type: "LOG_IN",
  emailAddress: "david.sheldrick@artsy.net"
})

// ...

dispatch({
  type: "LOG_IN_SUCCESS",
  accessToken: "038fh239h923908h"
})

Try it in the TypeScript playground

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:

1
2
3
4
5
// first argument is the 'type'
// second is any extra parameters
dispatch("LOG_IN_SUCCESS", {
  accessToken: "038fh239h923908h"
})

Deriving the type for that first argument is easy enough.

1
2
type ActionType = Action["type"]
// => "INIT" | "SYNC" | "LOG_IN" | "LOG_IN_SUCCESS"

But the type of the second argument depends on the first argument. We can use a type variable to model that dependency.

1
2
3
4
declare function dispatch<T extends ActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>
): void

Woah woah woah, what's this ExtractActionParameters voodoo?

It's a conditional type of course! Here's a first attempt at implementing it:

1
type ExtractActionParameters<A, T> = A extends { type: T } ? A : never

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:

1
2
type Test = ExtractActionParameters<Action, "LOG_IN">
// => { type: "LOG_IN", emailAddress: string }

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):

1
2
3
4
5
6
7
8
9
10
type ExcludeTypeKey<K> = K extends "type" ? never : K

type Test = ExcludeTypeKey<"emailAddress" | "type" | "foo">
// => "emailAddress" | "foo"

// here's the mapped type
type ExcludeTypeField<A> = { [K in ExcludeTypeKey<keyof A>]: A[K] }

type Test = ExcludeTypeField<{ type: "LOG_IN"; emailAddress: string }>
// => { emailAddress: string }

Then we can use ExcludeTypeField to redefine ExtractActionParameters.

1
2
3
type ExtractActionParameters<A, T> = A extends { type: T }
  ? ExcludeTypeField<A>
  : never

And now the new version of dipsatch is typesafe!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// All clear! :)
dispatch("LOG_IN_SUCCESS", {
  accessToken: "038fh239h923908h"
})

dispatch("LOG_IN_SUCCESS", {
  // Type Error! :)
  badKey: "038fh239h923908h"
})

// Type Error! :)
dispatch("BAD_TYPE", {
  accessToken: "038fh239h923908h"
})

Try it in the TypeScript playground

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.

1
dispatch("INIT", {})

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.

1
2
3
4
5
6
7
8
9
10
// And let's say that any actions that don't require
// extra parameters are 'simple' actions.
declare function dispatch(type: SimpleActionType): void
// this signature is just like before
declare function dispatch<T extends ActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>
): void

type SimpleActionType = ExtractSimpleAction<Action>['type']

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

1
type ExtractSimpleAction<A> = ExcludeTypeField<A> extends {} ? A : never

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:

1
type ExtractSimpleAction<A> = {} extends ExcludeTypeField<A> ? A : never

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.

1
2
3
4
5
type Blah<These, Are, Type, Variables> = ...

function blah<And, So, Are, These>() {
  ...
}

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:

1
type Blah<Var> = Var extends Whatever ? A : B

and these are not:

1
2
type Blah<Var> = Foo<Var> extends Whatever ? A : B
type Blah<Var> = Whatever extends Var ? A : B

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:

1
2
3
4
5
type ExtractSimpleAction<A> = A extends any
  ? {} extends ExcludeTypeField<A>
    ? A
    : never
  : never

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 πŸŽ‰πŸ•ΊπŸΌπŸ’ƒπŸ½πŸŽˆ

1
dispatch("INIT")

That's one yak successfully shaved βœ”


TypeScript provides a couple of built-in types that we could have used in this section:

1
2
3
4
5
// Exclude from U those types that are assignable to T
type Exclude<U, T> = U extends T ? never : U

// Extract from U those types that are assignable to T
type Extract<U, T> = U extends T ? U : never

e.g. instead of defining ExcludeTypeField like this:

1
type ExcludeTypeField<A> = { [K in ExcludeTypeKey<keyof A>]: A[K] }

we could have done this:

1
type ExcludeTypeField<A> = { [K in Exclude<keyof A, "type">]: A[K] }

And instead of defining ExtractActionParameters like this:

1
2
3
type ExtractActionParameters<A, T> = A extends { type: T }
  ? ExcludeTypeField<A>
  : never

we could have done this:

1
type ExtractActionParameters<A, T> = ExcludeTypeField<Extract<A, { type: T }>>

πŸ’‘ Exercise for the intrepid reader

Notice that this still works.

1
dispatch("INIT", {})

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.

1
2
3
4
5
6
type Unpack<A> = A extends Array<infer E> ? E : A

type Test = Unpack<Apple[]>
// => Apple
type Test = Unpack<Apple>
// => Apple

It handles ambiguity gracefully:

1
2
type Stairs = Unpack<Apple[] | Pear[]>
// => Apple | Pear

You can even use infer multiple times.

1
2
3
4
5
6
7
type Flip<T> = T extends [infer A, infer B] ? [B, A] : never
type Stairs = Flip<[Pear, Apple]>
// => [Apple, Pear]

type Union<T> = T extends [infer A, infer A] ? A : never
type Stairs = Union<[Apple, Pear]>
// => Apple | Pear

Other built-in conditional types

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Exclude null and undefined from T
type NonNullable<T> =
  T extends null | undefined ? never : T

// Obtain the parameters of a function type in a tuple
type Parameters<T> =
  T extends (...args: infer P) => any ? P : never

// Obtain the parameters of a constructor function type in a tuple
type ConstructorParameters<T> =
  T extends new (...args: infer P) => any ? P : never

// Obtain the return type of a function type
type ReturnType<T> =
  T extends (...args: any[]) => infer R ? R : any

// Obtain the return type of a constructor function type
type InstanceType<T> =
  T extends new (...args: any[]) => infer R ? R : any

Further reading