DEV Community

Cover image for Conditional Types in TypeScript.
Omotoso Abosede Racheal
Omotoso Abosede Racheal

Posted on

Conditional Types in TypeScript.

Introduction

Conditional types in TypeScript allow you to express type relationships using a form of ternary logic at the type level. They provide a way to perform type checks and return different types based on those checks. The syntax and basic usage of conditional types are similar to JavaScript's ternary operator.

Conditional types help describe the relation between the types of inputs and outputs.

T extend U ? X : Y
Enter fullscreen mode Exit fullscreen mode

Prerequisite

  • Javascript

What you will learn

  • Some Conditional types examples

  • Conditional Type Constraint

  • Inferring Within Conditional Types

  • Distributive Conditional Types

Some Conditional types examples

Conditional types take a form that looks a little like conditional expressions (condition ? trueExpression : falseExpression) in JavaScript:

SomeType extends OtherType ? TrueType : FalseType;
Enter fullscreen mode Exit fullscreen mode

When the type on the left of the extends is assignable to the one on the right, then you’ll get the type in the first branch (the “true” branch); otherwise, you’ll get the type in the latter branch (the “false” branch).

let’s take another example for a createLabel function:

interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}

function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}
Enter fullscreen mode Exit fullscreen mode

These overloads for createLabel describe a single JavaScript function that makes a choice based on the types of its inputs. Note a few things:

  • If a library has to make the same sort of choice over and over throughout its API, this becomes cumbersome.
  • We have to create three overloads: one for each case when we’re sure of the type (one for string and one for number), and one for the most general case (taking a string | number). For every new type createLabel can handle, the number of overloads grows exponentially.

Instead, we can encode that logic in a conditional type:

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;
Enter fullscreen mode Exit fullscreen mode

We can then use that conditional type to simplify our overloads down to a single function with no overloads.

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript");

let a: NameLabel

let b = createLabel(2.8);

let b: IdLabel

let c = createLabel(Math.random() ? "hello" : 42);
let c: NameLabel | IdLabel
Enter fullscreen mode Exit fullscreen mode

Conditional Type Constraint

In TypeScript, a conditional type constraint is a way to impose restrictions (constraint) on type parameters in generic types using conditional types. This technique allows you to express more complex type relationships and ensure that type parameters meet certain criteria.

Let's start with a basic example to illustrate how conditional type constraints work:

type CheckType<T> = T extends string ? "String Type" : "Other Type";

type A = CheckType<string>;  // "String Type"
type B = CheckType<number>;  // "Other Type"
Enter fullscreen mode Exit fullscreen mode

In this example, CheckType is a conditional type that checks if the type T extends string. If it does, it returns "String Type"; otherwise, it returns "Other Type".

Using Conditional Type Constraints in Generics

Conditional type constraints are often used within generic types to enforce more specific type rules.

Example: Constraining Function Parameters

Here's an example where a conditional type constraint ensures that a function parameter must be a specific type:

type EnsureString<T> = T extends string ? T : never;

function logString<T>(value: EnsureString<T>): void {
  console.log(value);
}

logString("Hello"); // Works
logString(42);   // Error: Argument of type '42' is not assignable to parameter of type 'never'.
Enter fullscreen mode Exit fullscreen mode

In this example, the EnsureString type ensures that T must be a string. If T is not a string, the type becomes never, which is an uninhabitable type and causes a type error.

More Complex Example: Constraining Based on Object Properties

You can also use conditional type constraints to enforce more complex rules, such as ensuring an object has specific properties:

type HasName<T> = T extends { name: string } ? T : never;

function greet<T>(obj: HasName<T>): void {
  console.log(`Hello, ${obj.name}`);
}

greet({ name: "Alice" });       // Works
greet({ name: "Bob", age: 30 }); // Works
greet({ age: 30 });             // Error: Argument of type '{ age: number; }' is not assignable to parameter of type 'never'.
Enter fullscreen mode Exit fullscreen mode

In this example, the HasName type ensures that T must be an object with a name property of type string. If T does not have this property, the type becomes never, causing a type error.

Inferring Within Conditional Types

In TypeScript, you can use the infer keyword within conditional types to introduce a type variable that can be inferred from a specific part of a type. This is particularly useful when working with complex types such as functions, tuples, and arrays, where you might want to extract a particular type of component.

The syntax for using infer within a conditional type is as follows:

type SomeType<T> = T extends SomeCondition ? infer U : FallbackType;
Enter fullscreen mode Exit fullscreen mode

Here, infer U allows TypeScript to infer the type U from T if T satisfies SomeCondition.

Examples

Extracting the Return Type of a Function

You can use infer to extract the return type of a function type:

type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Func = (a: number, b: string) => boolean;
type ReturnTypeOfFunc = GetReturnType<Func>;  // boolean
Enter fullscreen mode Exit fullscreen mode

In this example, GetReturnType checks if T is a function type. If it is, infer R captures the return type of the function, and GetReturnType resolves to that return type (R). Otherwise, it resolves to never.

Extracting the Element Type of an Array

You can use infer to extract the type of elements in an array:

type ElementType<T> = T extends (infer U)[] ? U : never;

type ArrayOfNumbers = number[];
type NumberElementType = ElementType<ArrayOfNumbers>;  // number
Enter fullscreen mode Exit fullscreen mode

Here, ElementType checks if T is an array type. If it is, infer U captures the element type, and ElementType resolves to that element type (U). Otherwise, it resolves to never.

Extracting Tuple Types

You can use infer to work with tuple types and extract their component types:

type FirstElement<T> = T extends [infer U, ...any[]] ? U : never;

type Tuple = [string, number, boolean];
type FirstType = FirstElement<Tuple>;  // string
Enter fullscreen mode Exit fullscreen mode

In this example, FirstElement checks if T is a tuple type. If it is, infer U captures the type of the first element, and FirstElement resolves to that type (U). Otherwise, it resolves to never.

Advanced Example: Extracting Parameter Types of a Function

You can use infer to extract the types of the parameters of a function:

type GetParameters<T> = T extends (...args: infer P) => any ? P : never;

type Func = (a: number, b: string) => void;
type ParametersOfFunc = GetParameters<Func>;  // [number, string]
Enter fullscreen mode Exit fullscreen mode

In this example, GetParameters checks if T is a function type. If it is, infer P captures the parameter types as a tuple, and GetParameters resolves to that tuple type (P). Otherwise, it resolves to never.

Distributive Conditional Types

When conditional types act on a generic type, they become distributive when given a union type. This means that if you have a conditional type T and T is a union, the conditional type will be applied to each member of the union.
For example, take the following:

type ToArray<Type> = Type extends any ? Type[] : never;
Enter fullscreen mode Exit fullscreen mode

If we plug a union type into ToArray, then the conditional type will be applied to each member of that union.

type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>;

type StrArrOrNumArr = string[] | number[]
Enter fullscreen mode Exit fullscreen mode

What happens here is that ToArray distributes on:

 string | number;
Enter fullscreen mode Exit fullscreen mode

and maps over each member type of the union, to what is effectively:

ToArray<string> | ToArray<number>;
Enter fullscreen mode Exit fullscreen mode

which leaves us with:

string[] | number[];
Enter fullscreen mode Exit fullscreen mode

Typically, distributivity is the desired behavior. To avoid that behavior, you can surround each side of the extends keyword with square brackets.

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 'ArrOfStrOrNum' is no longer a union.
type ArrOfStrOrNum = ToArrayNonDist<string | number>;

type ArrOfStrOrNum = (string | number)[]
Enter fullscreen mode Exit fullscreen mode

Conclusion

Conditional types in Typescript are powerful feature that enables advanced type manipulation and can help create more precise and flexible type definitions. They are particularly useful in generic programming and when working with complex type transformation.

Top comments (0)