Skip to main content

TS Ep 3: Advanced TypeScript — Conditional Types, Infer Keyword, and Template Literal Metaprogramming

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 3: This Article
TypeScript’s type system is Turing-complete. At the advanced level, types are not merely static annotations—they are functions that compute new type-level outputs at compile time.

In this third episode of our TotalTypeScript-inspired series, we master Advanced TypeScript Metaprogramming. We explore Conditional Types, Distributive Conditional Types, pattern matching with the infer keyword, Template Literal Types, Recursive Types (DeepReadonly, DeepPartial), and Type-Level State Machines.


1. Conditional Types (T extends U ? X : Y)
#

Conditional Types select one of two possible types based on a type relationship test:

type IsString<T> = T extends string ? true : false;

type Test1 = IsString<"hello">; // true
type Test2 = IsString<42>;      // false

Real-World Example: Dynamic Return Types
#

interface User { id: string; name: string }

// If IdOrIds is string, return User. If string[], return User[]
type GetUserResult<T extends string | string[]> = T extends string ? User : User[];

function getUser<T extends string | string[]>(idOrIds: T): GetUserResult<T> {
  if (typeof idOrIds === "string") {
    return { id: idOrIds, name: "Alice" } as GetUserResult<T>;
  }
  return [{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }] as GetUserResult<T>;
}

const singleUser = getUser("usr_101"); // Inferred type: User
const userList = getUser(["usr_101", "usr_102"]); // Inferred type: User[]

2. Distributive Conditional Types
#

When conditional types act on a generic type parameter <T> that is a naked union, they automatically distribute over every member of the union:

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

// Distributes over 'string | number': (string extends any ? string[] : never) | (number extends any ? number[] : never)
type Result = ToArray<string | number>; // string[] | number[]

Preventing Distribution
#

To prevent conditional types from distributing over unions, wrap the generic parameter in square brackets [T]:

type NonDistributiveToArray<T> = [T] extends [any] ? T[] : never;

type NonDistResult = NonDistributiveToArray<string | number>; // (string | number)[]

3. Pattern Matching with the infer Keyword
#

The infer keyword allows you to declare a type variable within the extends clause of a conditional type, extracting nested types via pattern matching.

// Extract Element Type from an Array
type ElementOf<T> = T extends (infer U)[] ? U : T;

type Str = ElementOf<string[]>; // string
type Num = ElementOf<number>;   // number

Unwrapping Promises (Awaited<T> Implementation)
#

type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;

type NestedPromise = Promise<Promise<string>>;
type Unwrapped = MyAwaited<NestedPromise>; // string

Extracting Function Return Types (ReturnType<T> Implementation)
#

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

function calculateScore() {
  return { score: 98, rank: "A+" };
}

type ScoreResult = MyReturnType<typeof calculateScore>; // { score: number; rank: string }

4. Template Literal Types and String Manipulation
#

Template Literal Types combine string literals with generic parameters to compute dynamic string types at compile time:

type EventType = "click" | "hover" | "focus";
type Target = "button" | "input";

// Computes union of all combinations: "click_button" | "click_input" | "hover_button" ...
type EventDescriptor = `${EventType}_${Target}`;

Built-in String Manipulation Utilities
#

TypeScript includes four built-in type utilities for string transformation:

type Route = "user" | "order" | "product";

type GetterName = `get${Capitalize<Route>}`; // "getUser" | "getOrder" | "getProduct"
type UpperRoute = Uppercase<Route>;           // "USER" | "ORDER" | "PRODUCT"

Real-World Event Bus Typing
#

type DynamicEvents = {
  userCreated: { id: string; email: string };
  orderPlaced: { orderId: string; total: number };
};

type EventHandlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (data: T[K]) => void;
};

const bus: EventHandlers<DynamicEvents> = {
  onUserCreated: (data) => console.log(data.email),
  onOrderPlaced: (data) => console.log(data.total),
};

5. Recursive Types: Deep Readonly and Deep Partial
#

Conditional types and mapped types can be recursive, applying transformations through nested object hierarchies:

type DeepReadonly<T> = T extends Function | boolean | number | string | null | undefined
  ? T
  : T extends Array<infer U>
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

type NestedConfig = {
  db: {
    host: string;
    ports: number[];
  };
};

type ImmutableConfig = DeepReadonly<NestedConfig>;

// ImmutableConfig.db.ports.push(8080); // Compiler Error! ReadonlyArray!

6. Type-Level State Machine
#

We can use template literal types and conditional types to construct type-safe state machines that validate state transitions at compile time:

type State = "idle" | "loading" | "success" | "error";

type ValidTransitions = {
  idle: "loading";
  loading: "success" | "error";
  success: "idle";
  error: "loading";
};

class StateMachine<TState extends State> {
  constructor(private state: TState) {}

  transition<TNext extends ValidTransitions[TState]>(nextState: TNext): StateMachine<TNext> {
    return new StateMachine(nextState);
  }

  getState(): TState {
    return this.state;
  }
}

const machine = new StateMachine("idle");
const loadingMachine = machine.transition("loading"); // Valid!
const successMachine = loadingMachine.transition("success"); // Valid!

// Compiler Error! Cannot transition directly from 'idle' to 'success'!
// machine.transition("success");

Key Takeaways
#

  1. Use Conditional Types for Computations: Use T extends U ? X : Y to compute return types dynamically based on input parameter shapes.
  2. Master infer Pattern Matching: Use infer inside extends clauses to extract promise payloads, return types, or tuple elements effortlessly.
  3. Build String Types with Template Literals: Combine ${T}_${U} and Capitalize<T> to generate typed event handlers and routing maps without manual duplication.
  4. Enforce State Safety: Build Type-Level State Machines to prevent invalid application state transitions at compile time.
typescript - This article is part of a series.
Part 3: This Article