Generics
Type parameters, constraints, inference, defaults โ and the rule for when a generic is actually earning its complexity.
A generic is a type with a hole in it. The caller fills the hole, and the relationship between input and output is preserved.
function first<T>(items: T[]): T | undefined {
return items[0]
}
const n = first([1, 2, 3]) // number | undefined
const s = first(["a", "b"]) // string | undefined
console.log(n, s?.toUpperCase())Without the type parameter you would need any[] (no safety) or one function per type (no reuse).
Inference
You almost never write the type argument. The compiler works it out from the values.
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b]
}
const p = pair("id", 42) // [string, number]
console.log(p[0].toUpperCase(), p[1].toFixed(1))
const explicit = pair<string, boolean>("flag", true) // when you want to be sure
console.log(explicit)Constraints
extends restricts what may fill the hole โ and gives you access to what you constrained.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b
}
console.log(longest("apple", "fig"))
console.log(longest([1, 2, 3], [1]))
const bad = longest(10, 20) // error! Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.The most useful constraint in practice is keyof:
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: 1, name: "Ada", active: true }
console.log(pluck(user, "name").toUpperCase()) // string
console.log(pluck(user, "id").toFixed(0)) // numberfunction pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: 1, name: "Ada" }
pluck(user, "email") // error! Argument of type '"email"' is not assignable to parameter of type '"id" | "name"'.Defaults
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }
const a: Result<number> = { ok: true, value: 1 }
const b: Result<number, string> = { ok: false, error: "nope" }
console.log(a.ok && a.value, b.ok === false && b.error)
function make<T = string>(v?: T): T | undefined {
return v
}
console.log(make(), make(7))Generic classes and interfaces
class Stack<T> {
private items: T[] = []
push(item: T): this {
this.items.push(item)
return this
}
pop(): T | undefined {
return this.items.pop()
}
get size(): number {
return this.items.length
}
}
const s = new Stack<string>()
s.push("a").push("b")
console.log(s.pop(), s.size)
const nums = new Stack<number>()
nums.push(1).push(2)
console.log(nums.pop()! + 10)interface Repository<T, Id = number> {
find(id: Id): T | undefined
all(): readonly T[]
}
type User = { id: number; name: string }
const users: Repository<User> = {
find: (id) => (id === 1 ? { id: 1, name: "Ada" } : undefined),
all: () => [{ id: 1, name: "Ada" }],
}
console.log(users.find(1)?.name, users.all().length)Inference from more than one place
When a type parameter appears twice, the compiler must reconcile both.
function zip<T>(a: T[], b: T[]): [T, T][] {
return a.map((v, i) => [v, b[i]!])
}
console.log(zip([1, 2], [3, 4]))
console.log(zip(["a"], ["b"]))
// Mixed inputs widen T to a union rather than failing:
console.log(zip<number | string>([1], ["a"]))When a generic is not worth it
A type parameter used once is almost always a disguised any.
// Pointless: T appears once, so it constrains nothing.
function logIt<T>(v: T): void {
console.log(v)
}
// Same behaviour, honest signature:
function logIt2(v: unknown): void {
console.log(v)
}
logIt(1)
logIt2(1)๐ก Tip: The test: does the type parameter appear in at least two positions โ two parameters, or a parameter and the return type? If not, it is not relating anything, and unknown says what you mean.
const type parameters
By default an argument widens on the way in. const T preserves the literal shape without the caller writing as const.
function routes<const T extends readonly string[]>(paths: T): T {
return paths
}
const r = routes(["/", "/about"]) // readonly ["/", "/about"], not string[]
const one: "/about" = r[1]
console.log(one, r.length)A worked example
Putting constraints, keyof and inference together โ a typed groupBy:
function groupBy<T, K extends PropertyKey>(
items: readonly T[],
key: (item: T) => K,
): Record<K, T[]> {
const out = {} as Record<K, T[]>
for (const item of items) {
const k = key(item)
;(out[k] ??= []).push(item)
}
return out
}
const people = [
{ name: "Ada", role: "engineer" as const },
{ name: "Grace", role: "admiral" as const },
{ name: "Linus", role: "engineer" as const },
]
const byRole = groupBy(people, (p) => p.role)
console.log(byRole.engineer.map((p) => p.name))
console.log(byRole.admiral.length)Reference: Generics in the TypeScript Handbook.