The original address: artsy. Making. IO/blog / 2018/1…

Condition types may not be used every day, but you may use them indirectly all the time. That’s because they work well with “plumbing” or “framework” code, dealing with API boundaries and behind-the-scenes kinda stuff.

Start with a condition type

Here is some JavaScript code:


function process(text) {

  return text && text.replace(/f/g, "p")

}

process("foo").toUpperCase()

Copy the code

In this code, it is clear that the.toupperCase () call is safe. Each time you pass a string to process, the function returns a string.

Note, however, that we can also pass some other argument to the function, such as NULL, which will return NULL. Calling toUpperCase() on the returned result will return an error.

Of course we can add some basic types to this function and let TypeScript check that we are using this function safely:


function process(text: string | null): string | null {

  return text && text.replace(/f/g, "p")

}

Copy the code

It seems safe to do so. What happens if you use it like before?

/ / ⌄ Type Error! :( process("foo").toUpperCase()Copy the code

TypeScript prompts a type error because it assumes that process(“foo”) might return NULL, even though we know that the result of the run is not actually returning NULL. But TypeScript has no way of predicting runtime state.

One way to help TypeScript understand this function better is to use overloading, which provides multiple type signatures for a function and lets TypeScript decide which one to use based on a given context.


function process(text: null): null;

function process(text: string): string;

function process(text: any): any {

  ...

}

Copy the code

So if we pass in a string, it will return a string. If we pass a NULL, it will return a NULL.

Now the function works as expected:

// All clear! Process ("foo").toupperCase () // ⌄ Type Error! :) process(null).toUpperCase()Copy the code

But here’s another use case that doesn’t work:

Declare a const maybeFoo: string | null / / ⌄ Type Error! :( process(maybeFoo)Copy the code

Try it yourself

TypeScript don’t allow us to pass in a type string | null parameter, because it cannot merge overloading statement. Added ak47 schematic item and loot added, or added condition type.


function process<T extends string | null>(

  text: T

): T extends string ? string : null {

  ...

}

Copy the code

As shown above, we introduce a type variable T to type the text parameter. Then we can use T as part of the conditional return type: T extends String? String: null. You may have noticed that this looks like a ternary expression. In fact, it does do something similar, but at compile time for the type system.

This covers all of our use cases:


typeof process("foo") // => string

typeof process(null) // => null

typeof process(maybeFoo) // => string | null

Copy the code

This is the conditional type, a ternary expression that always looks like this:


A extends B ? C : D

Copy the code

A, B, C, and D can be any type expression we know, but the point is on the left, A extends B condition.

Distribution can be

The extends keyword is at the heart of the conditional type. An extends B means that any value that meets the criteria of type A can safely be assigned to A variable of type B. In type system terms, A is assignable to B. Get a sense of distributability from this code:


declare const a: A

const b: B = a

// type check succeeds only if A is assignable to B

Copy the code

TypeScript uses a method called structural types to determine which types can be assigned to each other. This type system started appearing in mainstream languages about a decade ago, and if you have any experience with C# or Java you might find it a bit counterintuitive.

You’ve probably heard of the ducking type associated with dynamically typed languages. The phrase ducking type comes from the saying:

If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.

In the duck genre, we judge something by how it behaves, not by looking at who it is or tracing where it came from. Think of it as meritocracy. Structural types are a product of applying this idea to a static compile-time type system.

So TypeScript only cares about what a type can do, not what it’s called and where it fits in the type hierarchy.

Here’s a simple example:

➤ ➤ Class A {} class B {} const B: B = new A() // ➤ ➤ ➤ A = new B() // ➤ all good new A() instanceof B // => falseCopy the code

In the above code, TypeScript treats two completely unrelated types as equal because they have the same structure and capabilities. At run time, however, the two classes are not equivalent.

This is a good example of the significant semantic differences between TypeScript and JavaScript. This may seem like a problem because structure types are more flexible than Java-style nominal types, which focus more on names and hierarchy. But the two are not mutually exclusive, and some languages, such as Scala and Flow, allow you to mix them to solve specific problems.

In addition, distributability and structural types are both fairly straightforward in real code:

Interface Shape {color: string} class Circle {color: string radius: number} // configure All good! - Value - Error - Value - Value - Value - Value Not all shapes have a radius! const circle: Circle = shapeCopy the code

So structurally, A extends B looks like A is A superset of B. To be clear, type A contains all the attributes of type B, and possibly more.

One thing to watch out for is the literal type. In TypeScript you can use literals themselves as types.


let fruit: "banana" = "banana"

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

fruit = "apple"

Copy the code

“Banana” as a string has no more properties than any other string, but “banana” as a type is much more specific than string, allowing only “banana” to be assigned.

So there’s another way to understand A extends B: that A is A more concrete version of B. Here “specific” can be understood as having more attributes or being more specific, that is, having more limitations. A is A superset of B.

This brings us to the Top and Bottom types, the less specific types and the most specific types.

In type theory, a top-level type is a type that all other types can assign. If we don’t have any concrete information about a type, we can set that type to a top-level type. The top-level type is treated as a union of all possible types:

type Top = string | number | {foo: Bar} | Baz[] | ... | upCopy the code

TypeScript contains two top-level types: any and unknown.

  • Using any means that you can’t determine the type of value, so TypeScript assumes that you’re using it correctly and that there are no alarms

  • Using unknown means that there is no way to determine the type of a value, but TypeScript requires you to check the type at run time

A bottom type is a type that cannot be assigned by any other type, and no value can be assigned to a variable of that type. It can be thought of as an empty union type:

Type Bottom = ∅Copy the code

TypeScript has an underlying type: never. This type is very obvious, which means nothing.

When working with conditional types, it is useful to know the top-level and low-level types. Never is especially useful when using conditional types to refine union types.

Refine the union type based on the Distribution condition type

Conditional types can be used to filter out specific members of union types. To illustrate, first we define a union type Animal:


type Animal = Lion | Zebra | Tiger | Shark

Copy the code

If we need a function to filter out which felines are felines, we can write a utility type ExtractCat to do it:


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

type Cat = ExtractCat<Animal>

// => Lion | Tiger

Copy the code

I know lions and tigers don’t meow, but how cute would it be if they did ^_^

At first, this approach struck me as a bit confusing and magical. Let’s take a closer look at what TypeScript does with ExtractCat

:

First of all, it’s going toExtractCatRecursively applied toAnimalAll members of:


type Cat =

  | ExtractCat<Lion>

  | ExtractCat<Zebra>

  | ExtractCat<Tiger>

  | ExtractCat<Shark>

Copy the code

It then determines these condition types:


type Cat = Lion | never | Tiger | never

Copy the code

Now something interesting happens, remember no value can be of type never? So it makes no sense to include never in a union type, so TypeScript discards it:


type Cat = Lion | Tiger

Copy the code

In TypeScript, the use of this conditional type is called a distributive Conditional type.

This “distribution,” that is, the union type, expands recursively, but there is a limitation: it only happens when there is a common type variable to the left of the extends keyword. We’ll see what this means and how to break through this limitation in the next section.

A real-world use scenario for the distribution condition type

Some time ago I was writing a Chrome plugin that had a background script and a View script that ran in different execution contexts. They need to share state with each other, and the only way to do that is through serializable messaging. Redux inspired me to define a global union type, Action, as a model for messages that can be delivered in different contexts.


type Action =

  | {

      type: "INIT"

    }

  | {

      type: "SYNC"

    }

  | {

      type: "LOG_IN"

      emailAddress: string

    }

  | {

      type: "LOG_IN_SUCCESS"

      accessToken: string

    }

// ...

Copy the code

There is also a global dispatch function through which messages can be broadcast in different contexts.


declare function dispatch(action: Action): void

// ...

dispatch({

  type: "INIT"

})

// ...

dispatch({

  type: "LOG_IN",

  emailAddress: "[email protected]"

})

// ...

dispatch({

  type: "LOG_IN_SUCCESS",

  accessToken: "038fh239h923908h"

})

Copy the code

Try it yourself

The API is type-safe and works well with my IDE’s autocomplete feature, so I can just call it a day and move on to something else.

But there was one thought that stuck in my head that I’m sure most developers have.

The battle of ideas in the author’s mind is omitted here

I would like to call the dispatch function like this:


// first argument is the 'type'

// second is any extra parameters

dispatch("LOG_IN_SUCCESS", {

  accessToken: "038fh239h923908h"

})

Copy the code

Deriving the type of the first parameter is simple:


type ActionType = Action["type"]

// => "INIT" | "SYNC" | "LOG_IN" | "LOG_IN_SUCCESS"

Copy the code

But the type of the second parameter is determined by the first parameter, and we can model the dependency using a type variable:


declare function dispatch<T extends ActionType>(

  type: T,

  args: ExtractActionParameters<Action, T>

): void

Copy the code

So what is ExtractActionParameters here?

Obviously a condition type! Here is the first attempt to implement ExtractActionParameters:


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

Copy the code

This is very similar to the ExtractCat example, where we filter the Animals associative types by searching for meOW (). Here we filter the Action union type through the Type attribute. Let’s look at this in action:


type Test = ExtractActionParameters<Action, "LOG_IN">

// => { type: "LOG_IN", emailAddress: string }

Copy the code

So far, we are almost there, but the extracted result still retains type, which leads to the need to write type again when calling dispatch, which is inconsistent with our original intention.

Type attributes can be excluded by using a combination of condition types and keyof operators to implement a mapped type.

Mapping types allow you to create a new type by mapping the associated type of a key.

  • First, it can be usedkeyofThe operator takes all the keys of an existing type and returns them as a combined type
  • The union type of the key can then be filtered using the conditional type to return a filtered type

Let’s use a concrete example to demonstrate how to do this:


type ExcludeTypeKey<K> = K extends "type" ? never : K

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

// => "emailAddress" | "foo"


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

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

// => { emailAddress: string }

Copy the code

We can then use ExcludeTypeField to redefine ExtractActionParameters:


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

  ? ExcludeTypeField<A>

  : never

Copy the code

Now, this new version of the Dispatch function is type-safe:


// All clear! :)

dispatch("LOG_IN_SUCCESS", {

  accessToken: "038fh239h923908h"

})

dispatch("LOG_IN_SUCCESS", {

  // Type Error! :)

  badKey: "038fh239h923908h"

})

// Type Error! :)

dispatch("BAD_TYPE", {

  accessToken: "038fh239h923908h"

})

Copy the code

Try it yourself

One serious problem remains if an action has no arguments to pass, but I still need to write an empty object as the second argument to the Dispatch function.


dispatch("INIT", {})

Copy the code

This is a shameful waste. Tell Joe Biden not to wait for me to play mahjong tonight. I’m going to fix this, now! Immediately!!!!! 🤪

One thing that might immediately come to mind is to make the second argument optional, but this would lead to a new problem: actions that have arguments will be allowed if they don’t pass arguments, which is not type-safe.

It is better to define an overload of the dispatch function:


// 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']

Copy the code

So how do we define the condition type ExtractSimpleAction? We know that if we remove the type field of an action and return an empty object, then the action is a SimpleActionType. According to this idea, it seems that we can achieve this:


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

Copy the code

But this will not achieve our desired effect. Because ExcludeTypeField extends {} always returns true. This is because there is no case where the return value of ExcludeTypeField cannot be assigned to {}.

In this case, we swap the positions of the two parameters:


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

Copy the code

It now looks like ExcludeTypeField will branch true if it is empty, and false if it is not.

You think that’s the end of it? Don’t. This condition type has no effect. Some of you may remember this quote from earlier:

This “distribution,” where the union type is recursively expanded, only occurs when there is a common type variable to the left of the extends keyword

Type variables are often defined in generic parameter lists, wrapped in < and >. Such as:


type Blah<These, Are, Type, Variables> = ...


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

  ...

}

Copy the code

If you want the union type to recursively expand the application condition type, then the union type needs:

  • Bind to a type variable

  • This type variable needs to appear to the left of the extends keyword

Here is an example of an application condition type that can be expanded:


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

Copy the code

These are not:


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

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

Copy the code

When I discovered this limitation, I thought I had discovered a fundamental flaw in the underlying way a distribution condition type works. I think it might be a concession to algorithmic complexity. I think maybe my use cases are too advanced for TypeScript to handle.

But it turns out I was wrong, it’s just a practical language design to avoid extra syntax, and it’s easy to solve:


type ExtractSimpleAction<A> = A extends any

  ? {} extends ExcludeTypeField<A>

    ? A

    : never

  : never

Copy the code

As shown above, we are simply wrapping our logic around an extra condition type that will always execute true.

Finally, we can remove unnecessary code:


dispatch("INIT")

Copy the code

TypeScript provides some built-in types that we can use in this section:


// 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

Copy the code

Previously we implemented ExcludeTypeField like this:


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

Copy the code

Now we can do this:


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

Copy the code

Previously we implemented ExtractActionParameters like this:


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

  ? ExcludeTypeField<A>

  : never

Copy the code

Now we can implement it like this:


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

Copy the code

Let’s take a break

For now, the code shown below still works:


dispatch("INIT", {})

Copy the code

Using what you’ve learned so far, make an action with no arguments fail when passing a second argument.

useinferDeconstruction type

There is another keyword for conditional types: infer. It can be used anywhere in the type expression to the right of the extends keyword. You can use it to name any type that appears in that location. Such as:


type Unpack<A> = A extends Array<infer E> ? E : A


type Test = Unpack<Apple[]>

// => Apple

type Test = Unpack<Apple>

// => Apple

Copy the code

It can elegantly handle ambiguity:


type Stairs = Unpack<Apple[] | Pear[]>

// => Apple | Pear

Copy the code

You can even infer multiple times:


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

Copy the code

Other built-in condition types

We’ve already seen Exclude and Extract, and TypeScript provides several other conditional types out of the box.

// 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 : anyCopy the code

Further reading

  • The TypeScript 2.8 release notes

  • Microsoft/Typescript#21316 Conditional types pull request

  • Microsoft/Typescript#21496 inferpull request

  • lib.es5.d.ts#L1446 built-in conditional type definitions