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