TypeScript Generics Explained: From Basics to Real Patterns
Understand generic syntax, constraints, inference, and utility types through examples you will use in APIs, React components, and data layers.
TypeScript Generics Explained: From Basics to Real Patterns
Generics are the mechanism TypeScript uses to write reusable, type-safe abstractions without giving up precision. They appear in Array<T>, React's useState<T>, and every Promise<T>. Yet many developers copy generic syntax from Stack Overflow without knowing when to introduce a type parameter, how inference works, or why a constraint fixes a cryptic error.
This article builds generics from first principles through patterns you will recognize in production codebases.
The problem generics solve
Without generics, you choose between duplication and loss of type information:
function firstString(arr: string[]): string {
return arr[0];
}
function firstNumber(arr: number[]): number {
return arr[0];
}
One generic function preserves the element type:
function first<T>(arr: T[]): T {
return arr[0];
}
const n = first([1, 2, 3]); // number
const s = first(["a", "b"]); // string
The type parameter T is a placeholder filled in when the function is called. The compiler connects input and output: whatever T is, first returns T.
Generic functions and inference
Usually you omit explicit type arguments—TypeScript infers T from parameters:
first([true, false]); // T inferred as boolean
When inference fails, pass explicitly:
const rows = first<User>(maybeEmpty); // when maybeEmpty is User[] | undefined and you narrowed
Multiple type parameters model relationships:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
Generic interfaces and types
Types can be generic too:
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
async function fetchJson<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
if (!res.ok) return { success: false, error: res.statusText };
const data = (await res.json()) as T;
return { success: true, data };
}
T flows through callers—fetchJson<User>("/api/me") types data as User on the success branch.
Constraints with extends
If you need to access properties on T, constrain it:
function getId<T extends { id: string }>(entity: T): string {
return entity.id;
}
keyof constraints connect keys to object shapes:
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const email = pluck(user, "email"); // string
Common mistake: over-constraining. Start unconstrained; add extends only when the compiler demands it.
Generic classes
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
Instances fix T: new Stack<number>(). Static members cannot reference instance type parameters—use separate static generics if needed.
React component generics
Function components can be generic with a subtle syntax:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
};
function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map((item) => <li key={String(item)}>{renderItem(item)}</li>)}</ul>;
}
Usage: <List items={users} renderItem={(u) => u.name} /> infers T as User.
Hooks like useState<User | null>(null) fix state type explicitly when initial value is null.
Default type parameters
type Paginated<T, PageSize extends number = 20> = {
items: T[];
pageSize: PageSize;
cursor: string | null;
};
Defaults reduce noise when most callers want the standard page size.
Conditional types (light introduction)
Generics power conditional types:
type IsString<T> = T extends string ? true : false;
infer extracts types inside conditionals—used in advanced utilities:
type ReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
You use these via built-ins (ReturnType, Parameters, Awaited) before authoring your own.
Mapped types and keyof patterns
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
PartialBy<User, "email"> makes only email optional—handy for update DTOs.
Understanding generics makes Record, Pick, Omit, and Readonly readable instead of magic.
Variance intuition (practical level)
For function parameters, TypeScript checks assignability conservatively. Passing Dog[] where Animal[] expected can be unsafe for mutation (push(new Cat())). Read-only arrays and immutable data structures reduce pain.
When generics fail on function props, check whether you need readonly T[] or a covariant-friendly pattern.
Anti-patterns
<T = any>defaults erase safety—preferunknownor no default- Generic everything —
function log<T>(msg: T)adds nothing; usestring - Casting inside generics —
return data as Thides validation gaps - Ten-parameter generics — split into objects or builder types
Practice exercises (mental)
- Write
mapAsync<T, U>(items: T[], fn: (t: T) => Promise<U>): Promise<U[]> - Write
createStore<T extends object>(initial: T)returning[T, (patch: Partial<T>) => void]typed tuple - Type a
groupBythat returnsRecord<K, T[]>withK extends string | number
Debugging generic errors
- Read the inferred type hover in VS Code on the failing call
- Simplify: replace generic body with return type annotation temporarily
- Instantiate explicitly to see if inference or implementation is wrong
- Check strict optional and
exactOptionalPropertyTypesinteractions
Real-world patterns in libraries you use
React Query preserves data types through useQuery<User> so data is User | undefined, not unknown.
Zod infers output types from schemas: z.infer<typeof UserSchema> connects runtime validation to static types—generics bridge parse results and domain models.
Express/Fastify route typing with generics on handlers is improving; many teams wrap frameworks in typed routers where path params and bodies are generic parameters tied to a route map.
Studying one library's type definitions in node_modules (jump to definition) teaches more than reading abstract tutorials—look for how they constrain T extends QueryKey or similar.
Building your own typed utilities
Teams often need a typed pick, event emitter, or repository layer:
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<T>;
}
Implement once per aggregate (User, Order) with T carrying fields. Avoid Repository<any>—you lose autocomplete exactly where data access bugs hurt.
Generics and documentation
JSDoc @template T helps IDEs on JavaScript migration paths. For exported APIs, document constraints in prose: "T must include id for cache keys."
When not to use generics (revisited)
If you have only one implementation ever—parseConfig() reading your single config.json—explicit types are clearer. Generics earn their complexity when duplication would otherwise produce any or unsafe casts across three or more call sites.
Covariance and arrays (practical warning)
Cat[] is not safely assignable to Animal[] if you might push a Dog. TypeScript allows some array assignments that are unsound at runtime—when modeling animals use ReadonlyArray or avoid mutable shared arrays across types.
Generic type aliases vs interfaces
type Box<T> = { value: T };
Interfaces can merge declarations; type aliases suit unions and mapped types. Pick one style per module for readability.
Practice project ideas
Build a tiny typed event bus on<EventMap, K extends keyof EventMap>(event: K, handler: (payload: EventMap[K]) => void)—excellent generics exercise under two hundred lines.
Build a typed fetch wrapper that infers JSON bodies from a route map. You will feel inference limits quickly—that is the lesson.
Read TypeScript release notes annually—const type parameters and improved inference land regularly. One new feature per quarter in your codebase beats cramming every advanced type at once.
When stuck, simplify generics to concrete types first, make it work, then reintroduce type parameters once duplication appears—same refactor loop as extracting functions.
Pair generic utilities with unit tests that assert type inference using expectTypeOf—tests document intended relationships better than comments alone and catch accidental widening during refactors.
Conclusion
Generics are how TypeScript shares code without sharing types. Master inference, constraints, and keyof relationships; reach for conditional and mapped types when utilities repeat across modules.
Start every abstraction without generics; add type parameters only when duplicate overloads or any would otherwise appear. That discipline keeps APIs approachable while preserving the precision that makes TypeScript worth the build step entirely. Revisit generics when the third copy-paste appears—that is usually the right moment to generalize safely. Until then, concrete types ship faster and stay easier for reviewers to approve without debating variance theory in every single pull request.
Generics in API design
Expose generic helpers only when callers supply meaningful type parameters—otherwise use concrete types. Document constraints (extends) in TSDoc so inference failures make sense. Watch bundle size when generics instantiate huge unions in emitted declarations. For library authors, test inference with dtslint or equivalent. Generics should remove repetition, not impress readers. When teaching generics to your team, pair each abstraction with a failing example that breaks inference—those failures teach faster than reading utility type source.
Workshop: apply this week
Pick one idea from this article and ship it before Friday. Write a short internal note explaining what changed, what metric you expect to move, and how you will verify the result. Share the note with your team so the learning compounds. If the experiment fails, document the failure mode—it is as valuable as success for the next engineer reading this guide.
Frequently asked questions
- What is the point of TypeScript generics?
- Generics let you write functions, types, and components that work across multiple types while preserving the relationship between input and output types—so a function that returns what you pass in is typed accurately, not as any or a loose union.
- When should I add a generic constraint?
- Add extends when you need to access properties or methods on the type parameter—e.g., T extends { id: string } for entities with IDs. Without constraints, the type parameter is unknown shape except what you assign to it.
- Why does TypeScript sometimes infer generics and sometimes default to {}?
- Inference follows argument positions and contextual types. Empty object defaults often mean insufficient context—pass explicit type arguments or annotate parameters to guide the compiler.
Comments
Discussion is coming soon. Share this article and join the conversation on social media.
Enjoyed this article?
Get weekly engineering guides delivered to your inbox.