Having Fun with TypeScript: Conditional Types

Dagang Wei
4 min readApr 11, 2024

--

This blog post is part of the series Having Fun with TypeScript.

Introduction

Conditional types are an advanced feature in TypeScript that empower developers to express complex type relationships and transformations based on logical conditions. Think of them as the type system’s version of the ternary operator (condition ? resultIfTrue : resultIfFalse). They allow you to create new types dynamically, enabling greater flexibility and type safety within your TypeScript code.

What are Conditional Types?

At their core, conditional types take the following form:

TypeA extends TypeB ? TrueType : FalseType

Let’s break down the components:

  • TypeA: The type being checked.
  • extends: The keyword signifying a type constraint.
  • TypeB: The type against which TypeA is compared.
  • TrueType: The type returned if the condition (TypeA extends TypeB) evaluates to true.
  • FalseType: The type returned if the condition evaluates to false.

Why Use Conditional Types?

Type Inference with Precision: Conditional types allow TypeScript to infer and narrow down types more accurately within your codebase. This enhanced inference reduces the need for manual type annotations while making your code more self-documenting.

Reusable Type Logic: You can encapsulate reusable type transformation logic within conditional types, promoting cleaner code and minimizing repetition.

Flexible APIs: Conditional types let you design APIs that offer different return types or behaviors depending on the input types. This leads to more versatile functions and components.

Practical Examples

Filtering out null and undefined

type NonNull<T> = T extends null | undefined ? never : T;

// OK: Type is 'string'
let nonNullValue1: NonNull<string | null> = "hello";

// Error: Type 'null' is not assignable to type 'string'.
let nonNullValue2: NonNull<string | null> = null;

Extracting Array Element Types

type Flatten<T> = T extends any[] ? T[number] : T; 

type Flattened1 = Flatten<string[]>; // Type is 'string'
let flattened1 : Flattened1 = 'hello';

type Flattened2 = Flatten<number>; // Type is 'number'
let flattened2 : Flattened2 = 123;

Explanation:

  • Conditional Type: This defines a “conditional type.” It checks a type T and provides different results based on whether T meets a certain condition.
  • Condition: T extends any[] This part checks if the provided type T is an array (or more accurately, if it can be assigned to the any[] type).
  • True Branch: T[number] If T is an array, the Flatten type becomes T[number]. Arrays in TypeScript are indexed by numbers, so this is extracting the element type of the array.
  • False Branch: T If T is not an array, the type remains unchanged.

Modeling Options

interface User {
name: string;
age: number;
}

interface UserWithOptionalEmail {
name: string;
age: number;
email?: string;
}

type WithEmail<T> = T extends { email: string } ? T : T & UserWithOptionalEmail;

function processUser(user: WithEmail<User>) {
if (user.email) {
console.log("User email: ", user.email);
} else {
console.log("The user doesn't have an email");
}
}

const userWithEmail: WithEmail<User> = {
name: "Alice",
age: 30,
email: "alice@example.com"
};

const userWithoutEmail: WithEmail<User> = {
name: "Bob",
age: 25
};

processUser(userWithEmail);
processUser(userWithoutEmail);

Advanced Concepts

The infer Keyword

Introduce new type variables within conditional types to capture and infer types.

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

function addNumbers(x: number, y: number): number {
return x + y;
}

function greetUser(name: string): string {
return "Hello, " + name + "!";
}

// Get the return types
type AddNumbersReturnType = GetReturnType<typeof addNumbers>; // type will be 'number'
let num : AddNumbersReturnType = 123;

type GreetUserReturnType = GetReturnType<typeof greetUser>; // type will be 'string'
let s : GreetUserReturnType = 'hello';

type NeverType = GetReturnType<string>; // type will be 'never'
// Error: Type 'null' is not assignable to type 'never'.
let n : NeverType = null;

Explanation:

The GetReturnType type is a powerful TypeScript utility type that extracts the return type of a function. Here's how it works:

  1. Generic Type: It’s a generic type, meaning it takes another type (T) as an argument.
  2. Conditional Type: It employs a conditional type (T extends ... ? ... : ...) to check if the input type T is a function.
  3. infer Keyword: If T is a function, the infer R mechanism is used to capture the function's return type into a new type variable R.
  4. Return Value: Finally, GetReturnType<T> returns either R (the inferred return type) or never if T is not a function.

Distributive Conditional Types

Distributive conditional types in TypeScript apply a conditional type check to each member of a union type individually. The result of the conditional type is then formed into a new union. In a nutshell, they “distribute” over union types.

Let’s consider this example:

type BoxedValue<T> = T extends any ? { value: T } : never;

// BoxedValue<number | string > = { value: string; } | { value: number; }
let value1: BoxedValue<number | string > = { value : 123 };
let value2: BoxedValue<number | string> = { value : 'hello' };

// Error: Type 'number' is not assignable to type '{ value: string; } | { value: number; }'
let value3: BoxedValue<number | string> = 456;

Here’s how it works:

  • BoxedValue<T>: This conditional type takes a generic type T. If T is any type (any), it returns an object { value: T } (wrapping the value in a box). Otherwise, it returns never.
  • BoxedValues: We have a union type number | string. Due to the distributive nature of the conditional type, BoxedValue is applied to each member of the union:
  • BoxedValue<number> -> { value: number }
  • BoxedValue<string> -> { value: string }

Conclusion

Conditional types are a powerful tool in your TypeScript arsenal. By understanding how to apply them, you can achieve:

  • More expressive and type-safe code
  • Enhanced type inference capabilities
  • Increased flexibility and reusability within your codebase

While they may initially seem a bit complex, the benefits conditional types provide make them well worth the effort of learning.

--

--