TypeScript's generics are one of those features where the docs give you the syntax but real projects teach you the patterns. After working with TypeScript across several production codebases, here are five generic patterns I reach for constantly — with real examples, not identity<T>.
1. Constrained Generics: "Any Object With This Shape"
The basic generic is function identity<T>(arg: T): T. Useful for the docs, less useful on the job. What you actually need 90% of the time is a constrained generic — something that accepts any type matching a shape.
The pattern: Use extends to constrain what types can enter your generic, then use the narrowed type inside.
interface Identifiable {
id: string | number;
}
// Accepts anything with an 'id' field, returns it typed as-is
function findById<T extends Identifiable>(
items: T[],
id: T["id"]
): T | undefined {
return items.find((item) => item.id === id);
}
// Usage — T is inferred as User
const users: User[] = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" },
];
const alice = findById(users, 1);
// ^? User | undefined — retains full User type
Without the constraint, you'd either lose the specific type (returning Identifiable | undefined) or force callers to manually annotate. The constraint gives you the best of both: validation on input, specificity on output.
Real-world use case: API response handlers where different endpoints return different shapes but all have an id.
async function fetchResource<T extends Identifiable>(
url: string
): Promise<T[]> {
const res = await fetch(url);
const data: T[] = await res.json();
return data;
}
const products = await fetchResource<Product>("/api/products");
// ^? Product[] — not Identifiable[]
2. Conditional Return Types: Different Inputs, Different Outputs
Sometimes the return type of a function depends on what you pass in. A classic example: an event emitter where subscribing returns a cleanup function, but only if you pass specific options.
type SubscribeOptions = { once?: boolean };
// T is the event payload type, O controls the return type
function on<T, O extends SubscribeOptions | undefined = undefined>(
event: string,
handler: (payload: T) => void,
options?: O
): O extends { once: true } ? void : () => void {
// ... subscription logic ...
// Pretend this returns a cleanup function
return (() => {
// unsubscribe
}) as any;
}
// When you don't pass options, you get a cleanup function
const cleanup = on<UserCreated>("user.created", (payload) => {
console.log(payload.name);
});
// ^? () => void
// When you pass { once: true }, it returns void
on<UserCreated>("user.created", (payload) => {
console.log(payload.name);
}, { once: true });
// ^? void — no cleanup needed for one-shot listeners
This is cleaner than returning (() => void) | void and forcing every caller to narrow. The conditional return type communicates intent: "if you subscribe once, there's nothing to clean up."
Key insight: TypeScript resolves conditional types at the call site, not at the definition. The options parameter drives the return type automatically.
3. Typed Object Transformations with Mapped Generics
When you transform an object — renaming keys, wrapping values, adding prefixes — mapped generics keep the transformation type-safe.
// Wrap every value in an object with a getter
type Wrapped<T> = {
[K in keyof T]: {
get: () => T[K];
};
};
function wrapValues<T extends Record<string, any>>(obj: T): Wrapped<T> {
const result = {} as Wrapped<T>;
for (const key in obj) {
Object.defineProperty(result, key, {
get: () => obj[key],
});
}
return result;
}
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
const wrapped = wrapValues(config);
wrapped.apiUrl.get();
// ^? string — not any
wrapped.timeout.get();
// ^? number — not any
This pattern extends naturally to API response transformers, database row mappers, and Redux/Zustand store selectors. The generic preserves the exact shape of the input while transforming the value types.
4. Generic Inference from Arguments: Let TypeScript Do the Work
TypeScript can infer generic types from function arguments — but only if you structure your generics to give it enough information. The trick is using the argument to drive the generic, not the other way around.
// The function — T is inferred from the initial value argument
function createState<T>(initial: T) {
return {
get: () => initial,
set: (fn: (prev: T) => T) => { /* ... */ },
};
}
// ❌ Unnecessary: specifying the generic explicitly
const state = createState<{ name: string; count: number }>({
name: "counter",
count: 0,
});
// ✅ Better: let the argument infer the generic — same result, less noise
const state2 = createState({ name: "counter", count: 0 });
// ^? { get: () => { name: string; count: number }, set: (fn: ...) => void }
The real power shows when you have multiple generics that relate to each other:
function mergeObjects<T, U>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = mergeObjects({ name: "Alice" }, { age: 30 });
// ^? { name: string } & { age: number }
// — inferred from both arguments
If you ever find yourself manually writing FunctionName<MyType>(...) in your code, ask: can TypeScript infer this from the arguments? 90% of the time, yes — and the code gets cleaner.
5. Generic Constraint Inference with satisfies
TypeScript 4.9 introduced satisfies — and it pairs beautifully with generics. It validates that a value matches a generic constraint while preserving the most specific type.
// Without satisfies — you lose specificity
type EventMap = Record<string, (...args: any[]) => void>;
const handlers1: EventMap = {
user_created: (userId: string) => {},
item_purchased: (itemId: string, price: number) => {},
};
// handlers1.user_created("abc") — type is (...args: any[]) => void
// You lost the parameter types!
// With satisfies — validated but specific
const handlers2 = {
user_created: (userId: string) => {},
item_purchased: (itemId: string, price: number) => {},
} satisfies EventMap;
handlers2.user_created("abc");
// ^? (userId: string) => void — preserved!
handlers2.item_purchased("xyz", 29.99);
// ^? (itemId: string, price: number) => void — preserved!
Critical gotcha: If you try to use satisfies with a function type constraint like (payload: unknown) => void, strict mode (strictFunctionTypes) will fail because parameter types are contravariant:
// ❌ Won't compile under strict mode:
type Handler = (payload: unknown) => void;
const handlers = {
user_created: (payload: { id: string }) => {},
// ^^^^^^^^^ Type '(payload: { id: string }) => void' is not assignable
// to type '(payload: unknown) => void'
} satisfies Record<string, Handler>;
// ✅ Use ...args: any[] instead:
type SafeHandler = (...args: any[]) => void;
const safeHandlers = {
user_created: (payload: { id: string }) => {},
} satisfies Record<string, SafeHandler>; // OK
This is my most-reached-for pattern when building event systems, plugin architectures, and middleware pipelines.
Putting It All Together
Here's a realistic example combining all five patterns — a type-safe plugin system:
// 1. Plugin base with constrained generic
interface Plugin<TConfig extends Record<string, any> = {}> {
name: string;
config: TConfig;
// 2. Conditional lifecycle
setup: (config: TConfig) => void;
cleanup?: () => void;
}
// 3. Plugin registry — mapped generic over plugins
type PluginRegistry<T extends Record<string, Plugin<any>>> = {
[K in keyof T]: T[K] & { isLoaded: boolean };
};
// 4. Inference from arguments (TS 5.0's `const` type parameter keeps literal types)
function createPluginSystem<const T extends Record<string, Plugin<any>>>(
plugins: T
) {
const registry = {} as PluginRegistry<T>;
for (const [name, plugin] of Object.entries(plugins)) {
(registry as any)[name] = {
...plugin,
isLoaded: false,
};
}
return {
load: (name: keyof T) => {
registry[name].setup(registry[name].config);
registry[name].isLoaded = true;
},
};
}
// 5. Type-safe usage — everything inferred
const system = createPluginSystem({
analytics: {
name: "analytics",
config: { apiKey: "abc123" },
setup: (cfg) => console.log(`Analytics using: ${cfg.apiKey}`),
cleanup: () => console.log("Cleanup analytics"),
},
logger: {
name: "logger",
config: { level: "debug", output: "stdout" },
setup: (cfg) => console.log(`Logging at: ${cfg.level}`),
},
});
system.load("analytics");
system.load("logger");
// `system.load` only accepts valid plugin names — "analytics" | "logger"
// TypeScript catches typos like `system.load("analytic")` at compile time
When NOT to Use Generics
Generics aren't free. Every generic adds a cognitive cost for anyone reading your code. Use them when:
- You need type relationships between inputs and outputs (the most important reason)
- You're building reusable infrastructure — libraries, utilities, frameworks
- You want to enforce constraints at compile time instead of runtime
Skip them when:
- The function always does the same thing regardless of type
- A simple union type (
string | number) works fine - The generic only appears once (a concrete type is clearer)
Generics are TypeScript's most powerful tool for writing reusable, type-safe code. Start with constrained generics (pattern 1) and inference (pattern 4) — they give you the most value with the least complexity. Add conditional returns (pattern 2) and mapped generics (pattern 3) as your codebase grows. And if you haven't tried satisfies with generics (pattern 5), you're in for a treat.
What generic patterns do you reach for most? Drop them in the comments.
📚 TypeScript Series — Read the Full Collection
This article is part of my TypeScript deep-dive series. Check out the others:
- TypeScript Template Literal Types: String Manipulation at the Type Level — Build type-safe strings, CSS properties, and API paths with template literal types.
-
TypeScript
infer: The Keyword That Unlocks Advanced Type Extraction — Extract return types, unwrap Promises, and parse template literals at the type level. - TypeScript Generics: 5 Practical Patterns You'll Actually Use — Constrained generics, conditional return types, mapped generics, inference, and satisfies.
Working with TypeScript every day? I've collected 100+ AI prompts for debugging, code review, and shipping faster — tested across TypeScript, Python, and JavaScript. Check out the Developer Prompt Pack →
Top comments (0)