XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksTypeScriptAdvanced Generics & Conditional Types

Advanced Generics & Conditional Types

genericsconditional-typesadvancedhandbook

TL;DR

  • Generics let you write reusable, type-safe code that works with any type.
  • Conditional types (T extends U ? X : Y) let you branch type logic.
  • infer keyword lets you extract types from within other types.
  • Mapped types let you transform existing types property by property.

Step 1: Generic Constraints

Generic constraints exist because unconstrained generics are almost useless — TypeScript can't let you access .length on T if T might be a number. Constraints were added so you can say "T must have these properties" while keeping the function generic. This is how utility libraries (Lodash, Zod) type their APIs: constrained enough to be type-safe, generic enough to work with any conforming type. You'll use constraints in every generic function, component, or class you write.

Constraints restrict what types a generic can accept. Without them, TypeScript knows nothing about the shape of T.

// ❌ Without constraint — TS doesn't know T has .length
function logLength<T>(item: T) {
  console.log(item.length); // Error: Property 'length' does not exist
}

// ✅ With constraint — T must have a .length property
function logLength<T extends { length: number }>(item: T): void {
  console.log(item.length); // Works!
}

logLength("hello");      // ✅ string has .length
logLength([1, 2, 3]);    // ✅ array has .length
logLength(42);           // ❌ number doesn't have .length

Why This Matters

In interviews, you'll be asked: "How do you ensure a generic parameter has certain properties?" The answer is always extends.


Step 2: Multiple Type Parameters

Multiple generics were needed because real-world functions often relate different types to each other: "the key must be a valid key of the object, and the return type must be the value at that key." Without multiple parameters, you'd lose the relationship between input and output types, falling back to any. This pattern powers Object.keys, Array.map, form libraries, and any API that transforms one shape into another while maintaining type safety.

You can use multiple generics to maintain relationships between inputs and outputs.

// K must be a key of T — ensures type-safe property access
function pluck<T, K extends keyof T>(objects: T[], key: K): T[K][] {
  return objects.map(obj => obj[key]);
}

const users = [
  { name: "Alice", age: 30 },
  { name: "Bob", age: 25 },
];

const names = pluck(users, "name");  // type: string[]
const ages = pluck(users, "age");    // type: number[]
pluck(users, "email");               // ❌ Error: "email" not in User

Step 3: Conditional Types

Conditional types bring if/else logic to the type system — they were invented because many type transformations depend on what the input is. "If T is an array, extract the element type; otherwise keep T as-is." Before conditional types (TS 2.8, 2018), these transformations required overload after overload. They're the foundation of infer (extracting types from patterns), utility types like Extract/Exclude, and the distributive behavior that makes mapped utility types work on unions.

Conditional types are TypeScript's if/else for types. They follow the pattern: T extends U ? TrueType : FalseType

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

type A = IsString<"hello">;  // true
type B = IsString<42>;       // false

// Practical: Extract array element type
type ElementOf<T> = T extends (infer U)[] ? U : never;

type X = ElementOf<string[]>;    // string
type Y = ElementOf<number[]>;    // number
type Z = ElementOf<boolean>;     // never (not an array)

The infer Keyword

infer declares a type variable that TypeScript figures out for you:

// Extract function return type (this is how ReturnType<T> works internally)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Result = MyReturnType<() => string>;        // string
type Result2 = MyReturnType<(x: number) => boolean>; // boolean

// Extract Promise inner type (this is how Awaited<T> works)
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>;  // string
type B = UnwrapPromise<number>;           // number (not a Promise, returns as-is)

// Extract first argument type
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

type Arg = FirstArg<(name: string, age: number) => void>; // string

Step 4: Distributive Conditional Types

Distributive conditional types were designed so that utility types automatically work on unions without explicit iteration. When you write Exclude<'a' | 'b' | 'c', 'a'>, TypeScript distributes the conditional over each union member individually, giving you 'b' | 'c'. Without this behavior, you'd need to manually handle union types everywhere. It's also the reason type NonNullable<T> = T extends null | undefined ? never : T works: it processes each union member and filters out the unwanted ones.

When a conditional type acts on a union, it distributes over each member:

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

// Distributes: string[] | number[] (NOT (string | number)[])
type Result = ToArray<string | number>;

// Prevent distribution with [T] extends [any]
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

// Does NOT distribute: (string | number)[]
type Result2 = ToArrayNonDist<string | number>;

Practical: Exclude & Extract (Built-in utilities explained)

// Exclude<T, U> — remove types from a union
type Exclude<T, U> = T extends U ? never : T;

type Colors = "red" | "green" | "blue";
type Warm = Exclude<Colors, "blue">;  // "red" | "green"

// Extract<T, U> — keep only matching types
type Extract<T, U> = T extends U ? T : never;

type Nums = Extract<string | number | boolean, number | boolean>; // number | boolean

Step 5: Mapped Types

Mapped types were introduced because developers needed to transform all properties of a type systematically: "make everything optional", "make everything readonly", "change all values to booleans." Before mapped types, these required manually rewriting every property. They iterate over keys using in keyof and can modify both the key and value type. Every built-in utility type (Partial, Required, Readonly, Record) is implemented as a mapped type. Understanding them lets you build custom utilities for your domain.

Mapped types iterate over keys and transform them:

// Make all properties optional (this is Partial<T>)
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Make all properties nullable
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

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

type PartialUser = MyPartial<User>;
// { name?: string; age?: number; email?: string }

Key Remapping (TypeScript 4.1+)

// Prefix all keys with "get"
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number; getEmail: () => string }

// Filter keys by value type
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type StringProps = OnlyStrings<User>;
// { name: string; email: string }  — age removed because it's number

Step 6: Template Literal Types

Template literal types (TS 4.1, 2020) brought string manipulation into the type system because many APIs use string patterns: event names (onClick, onHover), CSS properties (margin-top), route paths (/users/:id). Before template literals, typing these required manual enumeration of every possible string. Now you can compute string types programmatically: on${Capitalize<string>} matches onClick, onHover, etc. They power type-safe routing, i18n keys, and the typed string patterns in libraries like tRPC.

Combine string literals with type-level string manipulation:

type EventName = "click" | "focus" | "blur";

// Generate handler names
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

// Intrinsic string manipulation types
type Upper = Uppercase<"hello">;        // "HELLO"
type Lower = Lowercase<"HELLO">;        // "hello"
type Cap = Capitalize<"hello">;         // "Hello"
type Uncap = Uncapitalize<"Hello">;     // "hello"

// Practical: Parse route params
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = ExtractParams<"/users/:id/posts/:postId">;
// "id" | "postId"

Step 7: Real-World Patterns

These patterns show how generic concepts combine in production code. The type-safe Builder pattern enforces that required methods are called before .build(). Generic API clients infer response types from endpoint definitions. Event emitters use mapped types to type event names and payloads. These aren't academic exercises — they're extracted from real libraries (Prisma's query builder, tRPC's router, Zod's schema builder). Mastering these patterns is what separates TypeScript users from TypeScript experts.

Pattern 1: Builder with Type Safety

type Builder<T extends Record<string, any>> = {
  set<K extends keyof T>(key: K, value: T[K]): Builder<T>;
  build(): T;
};

// Usage ensures you can only set valid keys with correct value types
declare function createBuilder<T>(): Builder<T>;

const user = createBuilder<User>()
  .set("name", "Alice")    // ✅
  .set("age", 30)          // ✅
  .set("age", "thirty")    // ❌ Error: string not assignable to number
  .build();

Pattern 2: Event Emitter

type EventMap = {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  error: { message: string; code: number };
};

class TypedEmitter<Events extends Record<string, any>> {
  on<E extends keyof Events>(event: E, handler: (payload: Events[E]) => void): void { /* ... */ }
  emit<E extends keyof Events>(event: E, payload: Events[E]): void { /* ... */ }
}

const emitter = new TypedEmitter<EventMap>();
emitter.on("login", (payload) => {
  // payload is { userId: string; timestamp: number } — fully typed!
  console.log(payload.userId);
});
emitter.emit("error", { message: "oops", code: 500 }); // ✅
emitter.emit("error", { message: "oops" });             // ❌ missing 'code'

Interview Questions

  1. What's the difference between extends in generics vs conditional types?

    • In generics: it's a constraint (T must satisfy U)
    • In conditional types: it's a test (if T satisfies U, then X, else Y)
  2. How does infer work?

    • It declares a type variable within a conditional type that TS infers from the pattern match.
  3. What's distributive behavior in conditional types?

    • When T is a naked type parameter and receives a union, the conditional distributes over each member individually.
  4. When would you use never?

    • To represent impossible states, filter out union members, or as the return type of functions that never return.
Type System EssentialsUtility Types Deep Dive

On this page

  • TL;DR
  • Step 1: Generic Constraints
  • Why This Matters
  • Step 2: Multiple Type Parameters
  • Step 3: Conditional Types
  • The infer Keyword
  • Step 4: Distributive Conditional Types
  • Practical: Exclude & Extract (Built-in utilities explained)
  • Step 5: Mapped Types
  • Key Remapping (TypeScript 4.1+)
  • Step 6: Template Literal Types
  • Step 7: Real-World Patterns
  • Pattern 1: Builder with Type Safety
  • Pattern 2: Event Emitter
  • Interview Questions