MEHEDI
Back to Blog

Understanding TypeScript Generics

10 min read
TypeScriptJavaScriptProgramming

Generics are how you write a function once and keep its type information intact for every caller. Without them you choose between duplicating a function per type or widening to any and losing the guarantees you adopted TypeScript for.

The problem generics solve

Consider a function that returns the first element of an array. Typed to take and return any, it compiles and tells you nothing. A type parameter connects the input to the output:

function first<T>(items: T[]): T | undefined {
  return items[0]
}

first([1, 2, 3])        // number | undefined
first(["a", "b"])       // string | undefined

Here T is a placeholder filled in at each call site. You never pass it explicitly, because TypeScript infers it from the argument.

Constraints

An unconstrained type parameter could be anything, so you cannot touch its properties. The extends keyword narrows what callers may pass, and in exchange lets you use what you now know is there:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b
}

Constraints are the main tool for making a generic both flexible and useful. Too loose and you cannot do anything with the value; too tight and callers cannot use it.

Using keyof for property access

The canonical example, and worth memorising, is a type-safe property getter:

function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

get({ name: "Ada", age: 36 }, "name")   // string
get({ name: "Ada", age: 36 }, "email")  // compile error

The return type T[K] is an indexed access type: the type of property K on T. The typo is caught at compile time rather than producing undefined at runtime.

Defaults

A type parameter can have a default, which is what lets a generic type be used without arguments:

interface ApiResponse<T = unknown> {
  data: T
  status: number
}

Prefer unknown to any as the default. Using unknown forces the consumer to narrow before use; any silently disables checking for everything downstream.

Conditional types

Types can branch on a condition, which is how the utility types in the standard library are built:

type Unwrap<T> = T extends Promise<infer U> ? U : T

type A = Unwrap<Promise<string>>  // string
type B = Unwrap<number>            // number

The infer keyword declares a placeholder that TypeScript fills in by pattern-matching the type. This is the mechanism behind ReturnType, Awaited and Parameters.

Distribution over unions

A conditional type applied to a bare type parameter distributes across a union, so unwrapping a union of two types gives you a union of two results rather than one result over a union. This surprises people. Wrapping both sides of the check in square brackets opts out of the distribution.

Knowing when to stop

Generics cost readability. A signature with four type parameters and nested conditionals is a maintenance problem regardless of how clever it is. Two questions before adding a type parameter:

  • Does this connect an input type to an output type? If not, you probably want a union or an overload.
  • Will a caller be worse off with a concrete type? If not, use the concrete type.

A generic that exists because it might be needed later is a generic that only makes today's code harder to read.

Comments

Comments

No comments yet. Be the first to comment!