Skip to content
Generics
step 1/7

Reading — step 1 of 7

Learn

~3 min readFunctions

Generics let a function or type work with multiple kinds of values WITHOUT using any. They preserve type information through your code — write a function once, get full type checking for every caller.

The motivating problem

Imagine writing first for arrays — a function that returns the first element:

// Wrong: returns any — caller loses type info
function first(arr: any[]): any {
    return arr[0];
}

const n = first([1, 2, 3]);    // n: any — not number!
n.foo();                         // no error, but crashes at runtime

any defeats the type checker. Could we write specific versions for every type? firstNumber, firstString, etc — that's absurd. Generics solve this:

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

const n = first([1, 2, 3]);     // n: number | undefined
const s = first(["a", "b"]);    // s: string | undefined

Type parameters — <T>

<T> introduces a type parameter. Read it as: "for some type T..." The compiler infers T from how the function is called — you rarely write it explicitly.

Multiple type parameters:

function pair<A, B>(a: A, b: B): [A, B] {
    return [a, b];
}

const p = pair("Alice", 30);    // p: [string, number]

Names T, U, K, V are conventional. Use longer names when meaning helps: <Item>, <Key, Value>.

Generic interfaces

interface Box<T> {
    value: T;
}

const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "hi" };

Generic types are reusable shapes parameterized by type. The standard library is full of them: Array<T>, Promise<T>, Map<K, V>, Set<T>.

Generic constraints — extends

Sometimes you need T to have certain shape:

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

longest("hello", "hi");       // OK — strings have length
longest([1, 2, 3], [4]);     // OK — arrays have length
longest(42, 5);                // ✗ compile error — numbers don't have length

T extends X constrains T to types that include X's shape.

Default type parameters

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

const raw: ApiResponse = { status: 200, data: "anything" };  // T defaults to unknown
const typed: ApiResponse<User> = { status: 200, data: user };

Defaults make a generic friendlier when callers don't care.

Generic classes

class Stack<T> {
    private items: T[] = [];
    push(item: T): void { this.items.push(item); }
    pop(): T | undefined { return this.items.pop(); }
    peek(): T | undefined { return this.items[this.items.length - 1]; }
}

const s = new Stack<number>();
s.push(1);
s.push(2);
const top = s.peek();    // top: number | undefined

Generic classes let you build typed data structures once.

When NOT to over-generic

Generics are a hammer. Don't make every function generic — most code works with concrete types. Reach for generics when:

  • The function works on collections of any type (sort, find, filter helpers).
  • You're building a reusable data structure or wrapper.
  • A factory or builder needs to preserve input → output types.

For a function that always handles User, just type it as User. Generics are for genuine polymorphism.

Common mistakes

  • Using any instead of T — defeats the type checker. Generics keep types flowing.
  • Type parameter without constraint when neededfunction first<T>(x: T) { return x.length; } fails because T isn't constrained to have a length.
  • Specifying T explicitly when inference worksfirst<number>([1, 2, 3]) is noise. Just first([1, 2, 3]).

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…