TypeScript Generics: How Inference Picks T, When to Constrain, and Signs a Generic Is Unneeded

Key takeaways

Generics in TypeScript: typed identity functions, generic functions and classes, constraints with extends and keyof, caches, and common mistakes—tutorial for reusable safe APIs.

Introduction

What are generics?

Generics let you treat types like parameters so you can build reusable, type-safe components.

The most useful way to think about a generic is that it expresses a relationship between types. identity<T>(value: T): T does not just say “takes anything, returns anything”. It says “whatever type goes in, the same type comes out”. getProperty<T, K extends keyof T>(obj: T, key: K): T[K] says “the key must belong to this object, and the result has the type of that key”. The compiler can only check what you tell it, and generics are how you tell it that two positions in a signature are linked.

That framing also gives you a quick test for whether a generic is doing anything. If a type parameter appears only once in a signature, there is no relationship to express, and the generic is really just a more verbose unknown, or a hidden cast. We will come back to this rule in the mistakes section, because it catches most over-engineered generic code.


Generics basics

The problem

If a function must accept many types, using any throws away safety:

// any — loses type safety
function identity(value: any): any {
    return value;
}
const result1 = identity("hello");  // any
const result2 = identity(123);      // any
// Issues:
// 1. Return type is any — no checking
// 2. result1.toFixed() might compile but fail at runtime
// 3. Input/output relationship is not expressed

The generic solution

// <T> declares a type parameter (T is conventional)
function identity<T>(value: T): T {
    return value;
}
const result1 = identity<string>("hello");
const result2 = identity<number>(123);
// Inference (preferred)
const result3 = identity("hello");  // T inferred as "hello" (a string literal type)
const result4 = identity(123);      // T inferred as 123 (a number literal type)
// Now the compiler preserves accuracy
// result3.toUpperCase();  // ✅
// result3.toFixed();      // ❌ error
// result4.toFixed(2);     // ✅

Benefits:

  1. Safety: checked at compile time
  2. Reuse: one implementation for many types
  3. Clarity: documents type relationships
  4. Tooling: better autocomplete Generics vs any | Aspect | Generics (<T>) | any | |--------|------------------|-------| | Safety | ✅ | ❌ | | Inference | ✅ | ❌ | | Autocomplete | ✅ | ❌ | | Runtime surprises | ✅ reduced | ❌ likely |

It helps to know how inference picks T, because it sometimes picks something different from what you expect. For identity("hello") above, result3 is not string, it is the literal type "hello": the argument is a literal and T is inferred from it as narrowly as possible. That is usually harmless, but it surprises people when a generic returns an object: wrap({ status: "ok" }) infers { status: string }, because object properties are widened. TypeScript 5.0 added const type parameters (function wrap<const T>(v: T)) for the cases where you want the literal types kept.

When inference has several candidates, it tries to find a common type. pair works fine, but a signature like function same<T>(a: T, b: T) called with same(1, "x") is an error (“string is not assignable to number”), because T is fixed from the first argument. This is usually what you want. It is the relationship “both arguments have the same type” being enforced.


Generic functions

Basics

function getFirstElement<T>(arr: T[]): T | undefined {
    return arr[0];
}
const numbers = [1, 2, 3];
const first = getFirstElement(numbers);
const strings = ["a", "b", "c"];
const firstStr = getFirstElement(strings);

Multiple type parameters

function pair<T, U>(first: T, second: U): [T, U] {
    return [first, second];
}
const result1 = pair("hello", 123);
const result2 = pair(true, "world");

Arrow functions

const map = <T, U>(arr: T[], fn: (item: T) => U): U[] => {
    return arr.map(fn);
};
const numbers = [1, 2, 3];
const doubled = map(numbers, (n) => n * 2);
const strings = map(numbers, (n) => n.toString());

In a .tsx file, const map = <T>(...) => ... fails to parse, because <T> looks like the start of a JSX element. Write <T,> (with a trailing comma) or <T extends unknown>, or use a function declaration. It is a small detail, but it is the first thing that trips up almost everyone who writes a generic helper inside a React component file.


Generic interfaces

Basics

interface Box<T> {
    value: T;
}
const numberBox: Box<number> = { value: 123 };
const stringBox: Box<string> = { value: "hello" };
const boxOfBoxes: Box<Box<number>> = {
    value: { value: 123 }
};

Example: API responses

interface ApiResponse<T> {
    success: boolean;
    data: T;
    error?: string;
}
interface User {
    id: string;
    name: string;
    email: string;
}
interface Product {
    id: string;
    name: string;
    price: number;
}
const userResponse: ApiResponse<User> = {
    success: true,
    data: {
        id: "U001",
        name: "Alice",
        email: "[email protected]"
    }
};
const productResponse: ApiResponse<Product[]> = {
    success: true,
    data: [
        { id: "P001", name: "Laptop", price: 1000000 },
        { id: "P002", name: "Mouse", price: 30000 }
    ]
};

This ApiResponse<T> shape is very common, and it has a weakness: success: false with a real data value, or success: true with an error, both type-check. A discriminated union makes those states impossible:

type ApiResult<T> =
    | { success: true; data: T }
    | { success: false; error: string };

function show(res: ApiResult<User>) {
    if (res.success) {
        console.log(res.data.name);  // data is available here
    } else {
        console.log(res.error);      // and error here
    }
}

The generic parameter works the same way, but now checking success narrows the type, and code cannot read data without handling the failure case first.


Generic classes

Basics

Generic classes are ideal for reusable data structures:

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];
    }
    isEmpty(): boolean {
        return this.items.length === 0;
    }
    size(): number {
        return this.items.length;
    }
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop());
console.log(numberStack.peek());
// numberStack.push("hello");  // ❌ error
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.pop());
// stringStack.push(123);  // ❌ error

Why generic classes help:

  • One class for many element types
  • No duplicate StackNumber, StackString, etc.
  • Prevents mixing wrong types in the same stack
const taskStack = new Stack<Task>();
const undoStack = new Stack<Action>();
const historyStack = new Stack<string>();

Keep in mind that generics exist only at compile time. TypeScript erases them, so at runtime Stack<number> and Stack<string> are the same class, and there is no way to write if (T is string) inside the class. Code that needs runtime type information has to receive it as a value, for example as a validator function or a schema passed to the constructor. This is a fundamental difference from C# or Java generics (and from C++ templates, which generate separate code for each type).


Generic constraints

extends

interface Lengthwise {
    length: number;
}
function logLength<T extends Lengthwise>(value: T): void {
    console.log(value.length);
}
logLength("hello");        // ✅
logLength([1, 2, 3]);      // ✅
// logLength(123);         // ❌ number has no length

Why use a generic here at all, instead of function logLength(value: Lengthwise)? For this function, you shouldn’t: it returns nothing, so T appears only once and adds nothing. The constraint becomes valuable when the function returns the value, as in function longest<T extends Lengthwise>(a: T, b: T): T. Without the generic, the return type would be Lengthwise, and the caller would lose the fact that they passed in strings or arrays. With the generic, longest("ab", "abc") still returns a string. A constraint sets the minimum a type must have, and the generic preserves everything else about it.

keyof

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
const user = {
    name: "Alice",
    age: 25,
    email: "[email protected]"
};
const name = getProperty(user, "name");
const age = getProperty(user, "age");
// const invalid = getProperty(user, "invalid");  // ❌ error

This is the pattern where generics clearly beat any alternative. The result type T[K] is looked up from the key you pass: name is a string and age is a number, from one implementation. With a plain (obj: object, key: string) signature, you would either return any or need an overload per key. Typed event emitters, form libraries, and ORMs use exactly this K extends keyof T + T[K] combination to make string keys type-safe.


Hands-on examples

Example 1: Chunk arrays

function chunk<T>(arr: T[], size: number): T[][] {
    const result: T[][] = [];
    for (let i = 0; i < arr.length; i += size) {
        result.push(arr.slice(i, i + size));
    }
    return result;
}
const numbers = [1, 2, 3, 4, 5, 6];
console.log(chunk(numbers, 2));
const strings = ["a", "b", "c", "d"];
console.log(chunk(strings, 3));

Example 2: Key–value cache

class Cache<K, V> {
    private store = new Map<K, V>();
    set(key: K, value: V): void {
        this.store.set(key, value);
    }
    get(key: K): V | undefined {
        return this.store.get(key);
    }
    has(key: K): boolean {
        return this.store.has(key);
    }
    delete(key: K): boolean {
        return this.store.delete(key);
    }
    clear(): void {
        this.store.clear();
    }
}
const userCache = new Cache<string, User>();
userCache.set("U001", { id: "U001", name: "Alice", email: "[email protected]" });
const user = userCache.get("U001");
console.log(user?.name);

One runtime detail the types do not show: Map compares keys by identity. Cache<string, User> works as expected, but with Cache<{ id: string }, User>, calling get({ id: "U001" }) returns undefined, because the object literal is a different object from the one used in set. The type system accepts it without complaint. Use primitive keys, or serialize object keys to strings.

Example 3: Promise wrapper

class AsyncResult<T> {
    constructor(private promise: Promise<T>) {}
    async map<U>(fn: (value: T) => U): Promise<AsyncResult<U>> {
        const value = await this.promise;
        return new AsyncResult(Promise.resolve(fn(value)));
    }
    async flatMap<U>(fn: (value: T) => Promise<U>): Promise<AsyncResult<U>> {
        const value = await this.promise;
        return new AsyncResult(fn(value));
    }
    async unwrap(): Promise<T> {
        return await this.promise;
    }
}
const result = new AsyncResult(Promise.resolve(10));
result
    .map((x) => x * 2)
    .then((r) => r.unwrap())
    .then((value) => console.log(value));  // 20

This example shows that the generics work (U is inferred as number from x * 2), but the design is awkward on purpose to show the limits. Because map is async, it returns Promise<AsyncResult<U>>, so you cannot chain .map().map() directly. A nicer version would return new AsyncResult(this.promise.then(fn)) synchronously, and then chaining works without any await. In real code, Promise<T> already is this wrapper (then works as both map and flatMap), so this class is only a teaching example.


Advanced patterns

Conditional types

type IsString<T> = T extends string ? true : false;
type A = IsString<string>;   // true
type B = IsString<number>;   // false
type C = IsString<string | number>;  // boolean (!)

C is boolean, not false. When the checked type is a bare type parameter, conditional types are distributive: a union is split, each member is checked separately, and the results are joined again (true | false). This behavior is what makes utility types like Exclude<T, U> work, but it is surprising when you want to test the union as a whole. Wrapping both sides in brackets turns it off: [T] extends [string] ? true : false.

Mapped types (preview)

type Readonly<T> = {
    readonly [K in keyof T]: T[K];
};
interface User {
    name: string;
    age: number;
}
type ReadonlyUser = Readonly<User>;
// { readonly name: string; readonly age: number; }

This redefines Readonly, which is already a built-in utility type, to show how it works internally. In a module file your local definition shadows the global one. Do not do this in real code. Use the built-in type (covered in the next article on utility types). Note also that readonly is shallow: nested objects inside a Readonly<User> can still be modified.


Common mistakes

Mistake 1: Accessing properties without a constraint

// ❌ Wrong
function getLength<T>(value: T): number {
    return value.length;  // T might not have length
}
// ✅ Correct
function getLength<T extends { length: number }>(value: T): number {
    return value.length;
}

Mistake 2: Unnecessary generics

// ❌ Generic adds nothing
function log<T>(message: string): void {
    console.log(message);
}
// ✅ Simpler
function log(message: string): void {
    console.log(message);
}

Mistake 3: A generic that is really a cast

// ❌ Looks type-safe, but T is never checked
async function fetchJson<T>(url: string): Promise<T> {
    const res = await fetch(url);
    return res.json();  // any, silently accepted as T
}
const user = await fetchJson<User>("/api/user/1");
user.email.toLowerCase();  // compiles; crashes if the API has no email

This is the single-use rule in its most dangerous form. T appears only in the return type, so nothing connects it to the actual data: the caller simply chooses a type, and the compiler believes it. It is as User in disguise, but it looks more trustworthy, because a generic signature suggests that something was checked.

This is the pattern I trust least in a TypeScript codebase, because its failures show up far from their cause. A backend renames email to emailAddress, every fetchJson<User> still compiles, and the error appears as Cannot read properties of undefined in some component three layers down, often only in production and only for some records. The type annotations make the code look safe right up to the crash. The fix is to validate at the boundary: return Promise<unknown> and parse it with a schema library such as Zod (UserSchema.parse(await res.json())), which gives you both the runtime check and the static type from one definition.


Next in the series