Featured image of post Conditional Types in TypeScript

Conditional Types in TypeScript

Overview

TypeScript 2.8 introduced conditional types, a powerful and exciting { 令人兴奋的 } addition to the type system. Conditional types let us express non-uniform { 不统一的,不一致的 } type mappings, that is, type transformations that differ depending on a condition.

Introduction to Conditional Types

A conditional type describes a type relationship test and selects one of two possible types, depending on the outcome of that test. It always has the following form:

1
T extends U ? X : Y

Conditional types use the familiar { 熟悉的;常见的 } ... ? ... : ... syntax that JavaScript uses for conditional expressions. T, U, X, and Y stand for arbitrary types. The T extends U part describes the type relationship test. If this condition is met, the type X is selected; otherwise the type Y is selected.

In human language, this conditional type reads as follows: If the type T is assignable to the type U, select the type X; otherwise, select the type Y.

Here’s an example for a conditional type that is predefined in TypeScript’s lib.es5.d.ts type definition file:

1
2
3
4
/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;

The NonNullable<T> type selects the never type if the type T is assignable to either the type null or the type undefined; otherwise it keeps the type T. The never type is TypeScript’s bottom type, the type for values that never occur.

Distributive Conditional Types

So why is the combination of a conditional type and the never type useful? It effectively allows us to remove constituent { 组成的 } types from a union type. If the relationship test in the conditional type checks a naked type parameter, the conditional type is called a distributive conditional type, and it is distributed over a union type when that union type is instantiated.

Since NonNullable<T> checks a naked type parameter, it is distributed over a union type A | B. This means that NonNullable<A | B> is resolved as NonNullable<A> | NonNullable<B>. If e.g. NonNullable<A> resolves to the never type, we can remove A from the resulting union type, effectively filtering out type A due to its nullability. The same is true for NonNullable<B>.

This description was fairly { 相当地 } abstract, so let’s look at a concrete example. We’ll define an EmailAddress type alias that represents a union of four different types, including the null and undefined unit types:

1
type EmailAddress = string | string[] | null | undefined;

Let’s now apply the NonNullable<T> type to EmailAddress and resolve the resulting type step by step:

1
type NonNullableEmailAddress = NonNullable<EmailAddress>;

We’ll start by replacing EmailAddress by the union type that it aliases:

1
2
3
type NonNullableEmailAddress = NonNullable<
  string | string[] | null | undefined
>;

Here’s where the distributive nature of conditional types comes into play. We’re applying the NonNullable<T> type to a union type; this is equivalent to applying the conditional type to all types in the union type:

1
2
3
4
5
type NonNullableEmailAddress =
  | NonNullable<string>
  | NonNullable<string[]>
  | NonNullable<null>
  | NonNullable<undefined>;

We can now replace NonNullable<T> by its definition everywhere:

1
2
3
4
5
type NonNullableEmailAddress =
  | (string extends null | undefined ? never : string)
  | (string[] extends null | undefined ? never : string[])
  | (null extends null | undefined ? never : null)
  | (undefined extends null | undefined ? never : undefined);

Next, we’ll have to resolve each of the four conditional types. Neither string nor string[] are assignable to null | undefined, which is why the first two types select string and string[]. Both null and undefined are assignable to null | undefined, which is why both the last two types select never:

1
type NonNullableEmailAddress = string | string[] | never | never;

Because never is a subtype of every type, we can omit it from the union type. This leaves us with the final result:

1
type NonNullableEmailAddress = string | string[];

And that’s indeed what we would expect our type to be!

Mapped Types with Conditional Types

Let’s now look at a more complex example that combines mapped types with conditional types. Here, we’re defining a type that extracts all non-nullable property keys from a type:

1
2
3
type NonNullablePropertyKeys<T> = {
  [P in keyof T]: null extends T[P] ? never : P;
}[keyof T];

This type might seem quite cryptic { 神秘的,含义模糊的 } at first. Once again, I’ll attempt to demystify { 使非神秘化 } it by looking at a concrete example and resolving the resulting type step by step.

Let’s say we have a User type and want to use the NonNullablePropertyKeys<T> type to find out which properties are non-nullable:

1
2
3
4
5
6
type User = {
  name: string;
  email: string | null;
};

type NonNullableUserPropertyKeys = NonNullablePropertyKeys<User>;

Here’s how we can resolve NonNullablePropertyKeys<User>. First, we’ll supply the User type as a type argument for the T type parameter:

1
2
3
type NonNullableUserPropertyKeys = {
  [P in keyof User]: null extends User[P] ? never : P;
}[keyof User];

Second, we’ll resolve keyof User within the mapped type. The User type has two properties, name and email, so we’ll end up with a union type with the "name" and "email" string literal types:

1
2
3
type NonNullableUserPropertyKeys = {
  [P in "name" | "email"]: null extends User[P] ? never : P;
}[keyof User];

Next, we’ll unroll { 展开 } the P in … mapping and substitute "name" and "email" for the P type:

1
2
3
4
type NonNullableUserPropertyKeys = {
  name: null extends User["name"] ? never : "name";
  email: null extends User["email"] ? never : "email";
}[keyof User];

We can then go ahead and resolve the indexed access types User["name"] and User["email"] by looking up the types of the name and email properties in User:

1
2
3
4
type NonNullableUserPropertyKeys = {
  name: null extends string ? never : "name";
  email: null extends string | null ? never : "email";
}[keyof User];

Now it’s time to apply our conditional type. null does not extend string, but it does extend string | null — we therefore end up with the "name" and never types, respectively:

1
2
3
4
type NonNullableUserPropertyKeys = {
  name: "name";
  email: never;
}[keyof User];

We’re now done with both the mapped type and the conditional type. Once more, we’ll resolve keyof User:

1
2
3
4
type NonNullableUserPropertyKeys = {
  name: "name";
  email: never;
}["name" | "email"];

We now have an indexed access type that looks up the types of the name and email properties. TypeScript resolves it by looking up each type individually and creating a union type of the results:

1
2
3
type NonNullableUserPropertyKeys =
  | { name: "name"; email: never }["name"]
  | { name: "name"; email: never }["email"];

We’re almost done! We can now look up the name and email properties in our two object types. The name property has type "name" and the email property has type never:

1
type NonNullableUserPropertyKeys = "name" | never;

And just like before, we can simplify the resulting union type by purging { 清洗;消除 } the never type:

1
type NonNullableUserPropertyKeys = "name";

That’s it! The only non-nullable property key in our User type is "name".

Let’s take this example one step further and define a type that extracts all non-nullable properties of a given type. We can use the Pick<T, K> type to , which is predefined in lib.es5.d.ts:

1
2
3
4
5
6
7
/**
 * From T, pick a set of properties
 * whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

We can combine our NonNullablePropertyKeys<T> type with Pick<T, K> to define NonNullableProperties<T>, which is the type that we were looking for:

1
2
3
4
type NonNullableProperties<T> = Pick<T, NonNullablePropertyKeys<T>>;

type NonNullableUserProperties = NonNullableProperties<User>;
// { name: string }

And indeed, this is the type we would expect: in our User type, only the name property is non-nullable.

Type Inference in Conditional Types

Another useful feature that conditional types support is inferring type variables using the infer keyword. Within the extends clause of a conditional type, you can use the infer keyword to infer a type variable, effectively performing pattern matching on types:

1
2
3
4
type First<T> = T extends [infer U, ...unknown[]] ? U : never;

type SomeTupleType = [string, number, boolean];
type FirstElementType = First<SomeTupleType>; // string

Note that the inferred type variables (in this case, U) can only be used in the true branch of the conditional type.

A long-standing { 长期存在的;存在已久的 } feature request for TypeScript has been the ability to extract the return type of a given function. Here’s a simplified version of the ReturnType<T> type that’s predefined in lib.es5.d.ts. It uses the infer keyword to infer the return type of a function type:

1
2
3
4
5
6
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type A = ReturnType<() => string>; // string
type B = ReturnType<() => () => any[]>; // () => any[]
type C = ReturnType<typeof Math.random>; // number
type D = ReturnType<typeof Array.isArray>; // boolean

Note that we have to use typeof to obtain the return type of the Math.random() and Array.isArray() methods. We need to pass a type as an argument for the type parameter T, not a value; this is why ReturnType<Math.random> and ReturnType<Array.isArray> would be incorrect.

For more information on how infer works, check out this pull request in which Anders Hejlsberg introduced type inference in conditional types.

Predefined Conditional Types

Conditional types are definitely an advanced feature of TypeScript’s type system. To give you some more examples of what they can be used for, I want to go over { 复习,重温;仔细检查 } the conditional types that are predefined in TypeScript’s lib.es5.d.ts file.

The NonNullable<T> Conditional Type

We’ve already seen and used the NonNullable<T> type which filters out the null and undefined types from T.

The definition:

1
2
3
4
/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;

Some examples:

1
2
3
4
type A = NonNullable<boolean>; // boolean
type B = NonNullable<number | null>; // number
type C = NonNullable<string | undefined>; // string
type D = NonNullable<null | undefined>; // never

Note how the empty type D is represented by never.

The Extract<T, U> Conditional Type

The Extract<T, U> type lets us filter the type T and keep all those types that are assignable to U.

The definition:

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

Some examples:

1
2
3
4
type A = Extract<string | string[], any[]>; // string[]
type B = Extract<(() => void) | null, Function>; // () => void
type C = Extract<200 | 400, 200 | 201>; // 200
type D = Extract<number, boolean>; // never

The Exclude<T, U> Conditional Type

The Exclude<T, U> type lets us filter the type T and keep those types that are not assignable to U. It is the counterpart { 对应的人(或事物) } of the Extract<T, U> type.

The definition:

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

Some examples:

1
2
3
4
type A = Exclude<string | string[], any[]>; // string
type B = Exclude<(() => void) | null, Function>; // null
type C = Exclude<200 | 400, 200 | 201>; // 400
type D = Exclude<number, boolean>; // number

The ReturnType<T> Conditional Type

As we’ve seen above, the ReturnType<T> lets us extract the return type of a function type.

The definition:

1
2
3
4
5
6
7
8
/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any[]) => any> = T extends (
  ...args: any[]
) => infer R
  ? R
  : any;

Some examples:

1
2
3
4
type A = ReturnType<() => string>; // string
type B = ReturnType<() => () => any[]>; // () => any[]
type C = ReturnType<typeof Math.random>; // number
type D = ReturnType<typeof Array.isArray>; // boolean

The Parameters<T> Conditional Type

The Parameters<T> type lets us extract all parameter types of a function type. It produces a tuple type with all the parameter types (or the type never if T is not a function).

The definition:

1
2
3
4
5
6
7
8
/**
 * Obtain the parameters of a function type in a tuple
 */
type Parameters<T extends (...args: any[]) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;

Notice that the Parameters<T> type is almost identical in structure to the ReturnType<T> type. The main difference is the placement of the infer keyword.

Some examples:

1
2
3
4
type A = Parameters<() => void>; // []
type B = Parameters<typeof Array.isArray>; // [any]
type C = Parameters<typeof parseInt>; // [string, (number | undefined)?]
type D = Parameters<typeof Math.max>; // number[]

The Array.isArray() method expects exactly one argument of an arbitrary type; this is why type B is resolved as [any], a tuple with exactly one element. The Math.max() method, on the other hand, expects arbitrarily many numeric arguments (not a single array argument); therefore, type D is resolved as number[] (and not [number[]]).

The ConstructorParameters<T> Conditional Type

The ConstructorParameters<T> type lets us extract all parameter types of a constructor function type. It produces a tuple type with all the parameter types (or the type never if T is not a function).

The definition:

1
2
3
4
5
6
/**
 * Obtain the parameters of a constructor function type in a tuple
 */
type ConstructorParameters<
  T extends new (...args: any[]) => any
> = T extends new (...args: infer P) => any ? P : never;

Notice that the ConstructorParameters<T> type is almost identical to the Parameters<T> type. The only difference is the additional new keyword that indicates that the function can be constructed. Some examples:

1
2
3
4
5
6
7
8
type A = ConstructorParameters<ErrorConstructor>;
// [(string | undefined)?]

type B = ConstructorParameters<FunctionConstructor>;
// string[]

type C = ConstructorParameters<RegExpConstructor>;
// [string, (string | undefined)?]

The InstanceType<T> Conditional Type

The InstanceType<T> type lets us extract the return type of a constructor function type. It is the equivalent of ReturnType<T> for constructor functions.

The definition:

1
2
3
4
5
6
7
8
/**
 * Obtain the return type of a constructor function type
 */
type InstanceType<T extends new (...args: any[]) => any> = T extends new (
  ...args: any[]
) => infer R
  ? R
  : any;

Once again, notice how the InstanceType<T> type is very similar in structure to the ReturnType<T> and ConstructorParameters<T> types.

Some examples:

1
2
3
type A = InstanceType<ErrorConstructor>; // Error
type B = InstanceType<FunctionConstructor>; // Function
type C = InstanceType<RegExpConstructor>; // RegExp

References

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy