How TypeScript Generics Actually Work
A clear explanation of how TypeScript generics work — real patterns, constraints, and when not to use them, with minimal code.
Most TypeScript developers reach for any when they need a function to work across multiple types. It compiles, autocomplete breaks, and subtle bugs hide until production. Generics are the fix — but the syntax trips people up enough that any keeps winning.
The mental model that actually works: generics are functions for your type system. A normal function takes a value and returns a value. A generic takes a type and returns a new type. Once that clicks, the angle brackets stop looking like line noise.
The Problem They Solve
Say you're wrapping API responses. Without generics, you write a separate interface for every resource:
interface UserApiResponse {
data: User;
status: "success" | "error";
timestamp: number;
}
interface ProductApiResponse {
data: Product;
status: "success" | "error";
timestamp: number;
}Fifty resources, fifty interfaces. Or you reach for any on the data field and lose all editor support. Generics collapse both approaches into one:
interface ApiResponse<T> {
data: T;
status: "success" | "error";
timestamp: number;
}
// The compiler now knows exactly what lives in .data
const user: ApiResponse<User> = { data: { id: "1", email: "dev@example.com" }, status: "success", timestamp: Date.now() };
const product: ApiResponse<Product> = { data: { id: "2", sku: "TS-01", price: 99 }, status: "success", timestamp: Date.now() };T is a placeholder. When you write ApiResponse<User>, the compiler substitutes User everywhere T appears. It's substitution, not magic.
Three Things the Compiler Does With Generics
Type erasure. At runtime, there are no generics. The TypeScript compiler validates everything and then strips every type annotation before emitting JavaScript. Generics have zero performance cost — they exist only for the compiler.
Type inference. You don't always need to write the angle bracket explicitly. When you call wrapInArray("hello"), the compiler sees a string argument and infers T = string automatically. Explicit type arguments (wrapInArray<string>("hello")) are only necessary when inference doesn't have enough information.
Constraints. Sometimes any type is too permissive. The extends keyword narrows what's allowed:
function logEntityId<T extends { id: string }>(entity: T): void {
console.log(`Processing: ${entity.id}`);
}
logEntityId({ id: "123", name: "Widget" }); // fine
logEntityId({ name: "No ID here" }); // compile errorThe constraint says: "T must have at least an id: string property." The function works on any shape that satisfies that contract — not just one specific type.
A Real Pattern: Type-Safe Event Broker
This is where generics earn their keep. A typed event broker maps event names to their exact payload shapes at compile time:
type EventMap = Record<string, unknown>;
class TypedEventBroker<TEventMap extends EventMap> {
private listeners: { [K in keyof TEventMap]?: Set<(payload: TEventMap[K]) => void> } = {};
on<K extends keyof TEventMap>(event: K, listener: (payload: TEventMap[K]) => void): void {
(this.listeners[event] ??= new Set()).add(listener);
}
emit<K extends keyof TEventMap>(event: K, payload: TEventMap[K]): void {
this.listeners[event]?.forEach(fn => fn(payload));
}
}
interface AppEvents {
"user:signup": { userId: string; referralCode?: string };
"payment:success": { transactionId: string; amount: number };
}
const broker = new TypedEventBroker<AppEvents>();
broker.on("user:signup", ({ userId }) => console.log(userId)); // payload fully typed
broker.emit("user:signup", { userId: 12345 }); // compile error: number is not stringThe generic constraint K extends keyof TEventMap ensures you can only subscribe to and emit events that actually exist in your schema. Wrong event name? Compile error. Wrong payload shape? Compile error. Zero runtime overhead.
When Not to Use Generics
The most common mistake is genericizing things that don't need to be generic. If you only need to accept one type, use that type. If you only need to accept anything with a .length, use an interface:
// Over-engineered
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
// Just right
function getLength(arg: { length: number }): number {
return arg.length;
}Generics earn their complexity when you need to preserve a relationship between types — the input type determines the output type, or the argument type constrains which keys are valid. If there's no such relationship to preserve, a concrete type or interface is simpler and just as safe.
One Immediate Change Worth Making
Search your shared utilities for functions that accept or return any. The highest-value swap is usually a key-lookup function:
// Before
function pluck(obj: any, key: string): any { return obj[key]; }
// After — compiler validates the key exists and infers the return type
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }The after version lets the editor autocomplete valid keys and catches typos at compile time instead of at runtime. That's the deal generics offer: more safety, zero cost.
Related Posts
Beyond Promise.all: Advanced TypeScript Async Await Patterns for High-Throughput Systems
Master advanced typescript async await patterns to eliminate race conditions, handle backpressure, and build highly resilient concurrent systems without breaking your type safety.
TypeScript MCP Agent Framework: A Production Guide
Build a lightweight MCP agent framework in TypeScript using native JSON-RPC and stdio — no bloated SDKs, no framework tax.
Beyond the Chatbox: How to Build Generative UI with LLMs and TypeScript
Learn how to build generative ui with llms using TypeScript. Step-by-step guide to building a neural expressive interface that bypasses the dead chat-log pattern.