Marius Schulz
Marius Schulz
Front End Engineer

Conditional Types in TypeScript

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:

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:

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

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

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

type NonNullableEmailAddress = NonNullable<EmailAddress>;

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

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:

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

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

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

type NonNullableUserPropertyKeys =
  | "name"
  | never;

And just like before, we can simplify the resulting union type by purging the never type:

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:

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

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:

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:

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:

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

Some examples:

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:

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

Some examples:

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:

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

Some examples:

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:

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

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:

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

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:

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

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:

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

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

This post is part of the TypeScript Evolution series.

Buy Me a Coffee