ResourcesTypeScript TipsAdvanced TypeScript Generics Explained Simply
📘TypeScript TipsAdvanced TypeScript Generics Explained Simply8 min

Advanced TypeScript Generics Explained Simply

Constraints, conditional types, infer, and variance, TypeScript generics go deep. Here's how to actually understand them.

📅January 24, 2026TechTwitter.iotypescriptgenericsadvanced

Beyond function identity<T>(x: T): T

Most TypeScript tutorials stop at basic generic functions. But the real power of TypeScript's type system is in advanced generic patterns. Here's a practical guide to the features that make complex typing possible.


Constraints: extends

The extends keyword in generics constrains what types are accepted:

// Without constraint — T could be anything
function getLength<T>(x: T): number {
  return x.length // Error: T doesn't necessarily have .length
}

// With constraint — T must have a .length property
function getLength<T extends { length: number }>(x: T): number {
  return x.length // Safe
}

getLength("hello")      // ✅
getLength([1, 2, 3])    // ✅
getLength(42)           // ❌ Error: number has no .length

Multiple Type Parameters with Relationships

// K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { id: 1, name: 'Alice', email: 'alice@example.com' }

const name = getProperty(user, 'name')   // string ✅
const id = getProperty(user, 'id')       // number ✅
const bad = getProperty(user, 'phone')   // ❌ Error: 'phone' not in keyof user

Conditional Types

Conditional types let you branch the type system based on conditions:

type IsArray<T> = T extends any[] ? true : false

type A = IsArray<string[]>  // true
type B = IsArray<string>    // false

More useful: extracting element types:

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

type E1 = ElementType<string[]>   // string
type E2 = ElementType<number[]>   // number
type E3 = ElementType<string>     // never

infer — Extracting Types Within Conditionals

infer lets you capture a type from within a conditional:

// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T

type A = Awaited<Promise<string>>   // string
type B = Awaited<string>            // string (not a promise, returns T)

// Extract parameter types
type FirstParam<T extends (...args: any) => any> =
  T extends (first: infer P, ...rest: any[]) => any ? P : never

type F = FirstParam<(x: number, y: string) => void>  // number

Distributive Conditional Types

When you apply a conditional type to a union, it distributes over each member:

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

type Result = ToArray<string | number>
// = ToArray<string> | ToArray<number>
// = string[] | number[]

To prevent distribution, wrap in a tuple:

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

type Result = ToArrayNonDist<string | number>
// = (string | number)[]  — one array type, not a union of arrays

Mapped Types with Key Remapping

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

interface User {
  name: string
  age: number
}

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

Generic Defaults

Type parameters can have defaults, just like function parameters:

interface ApiResponse<T = unknown, E = Error> {
  data: T | null
  error: E | null
  status: number
}

// All valid
const r1: ApiResponse = { data: null, error: null, status: 200 }
const r2: ApiResponse<User> = { data: user, error: null, status: 200 }
const r3: ApiResponse<User, ValidationError> = { ... }

Builder Pattern with Generics

Generics shine in builder patterns where state accumulates:

class QueryBuilder<T extends object, Selected extends keyof T = never> {
  select<K extends keyof T>(
    ...fields: K[]
  ): QueryBuilder<T, Selected | K> {
    // return new builder with expanded Selected type
    return this as any
  }

  build(): Pick<T, Selected> {
    // ...
  }
}

interface User {
  id: number
  name: string
  email: string
  role: string
}

const result = new QueryBuilder<User>()
  .select('id', 'name')
  .build()
// Type: Pick<User, 'id' | 'name'>
// = { id: number; name: string }

Key Takeaways

  • Constraints (extends) — limit what types a generic accepts
  • Conditional types — branch the type system like an if/else
  • infer — capture and name types inside conditionals
  • Distributive conditionals — automatically distribute over unions
  • Mapped types with remapping — transform key names, not just values
  • Generics get powerful when you combine these — start simple, add complexity only when the type system is fighting you