Mastering TypeScript Generics for Better APIs
Generics can be intimidating, but they are the secret to writing scalable, type-safe, and reusable code in TypeScript. Here's a deep dive into practical use cases.
Tags
Mastering TypeScript Generics for Better APIs
TL;DR
TypeScript generics let you write flexible, reusable functions and components that preserve full type information, eliminating the need for any while keeping your code DRY and type-safe.
Prerequisites
- ›TypeScript 5+ configured in a project
- ›Solid understanding of TypeScript basics (interfaces, types, type inference)
- ›Familiarity with functions, classes, and async patterns
- ›A code editor with TypeScript language server (VS Code recommended)
Step 1: Understanding the Problem Generics Solve
Without generics, you face an uncomfortable choice: duplicate code for each type, or use any and lose type safety.
// Option A: Duplicate code for every type
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
// Option B: Use "any" and lose type information
function getFirst(arr: any[]): any {
return arr[0];
}
const result = getFirst(["hello", "world"]);
// result is "any" -- TypeScript cannot help you hereThe Generic Solution
Generics introduce a type variable that captures the input type and carries it through to the output.
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const str = getFirst(["hello", "world"]); // type: string | undefined
const num = getFirst([1, 2, 3]); // type: number | undefined
const user = getFirst([{ name: "Alice" }]); // type: { name: string } | undefinedTypeScript infers T from the argument. You can also specify it explicitly when inference is ambiguous:
const result = getFirst<string>(["hello"]);Step 2: Generic Functions
Build utility functions that work with any type while preserving type safety.
// A generic function that groups array items by a key
function groupBy<T, K extends keyof T>(items: T[], key: K): Record<string, T[]> {
return items.reduce((groups, item) => {
const groupKey = String(item[key]);
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(item);
return groups;
}, {} as Record<string, T[]>);
}
interface Product {
id: number;
name: string;
category: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: "Laptop", category: "electronics", price: 999 },
{ id: 2, name: "Shirt", category: "clothing", price: 29 },
{ id: 3, name: "Phone", category: "electronics", price: 699 },
];
const grouped = groupBy(products, "category");
// grouped.electronics -> Product[]
// grouped.clothing -> Product[]
// TypeScript catches invalid keys at compile time:
// groupBy(products, "invalid"); // Error: Argument of type '"invalid"' is not assignableMultiple Type Parameters
Functions can accept multiple generic parameters when the types are independent.
function zip<A, B>(arr1: A[], arr2: B[]): [A, B][] {
const length = Math.min(arr1.length, arr2.length);
const result: [A, B][] = [];
for (let i = 0; i < length; i++) {
result.push([arr1[i], arr2[i]]);
}
return result;
}
const pairs = zip(["a", "b", "c"], [1, 2, 3]);
// type: [string, number][]
// value: [["a", 1], ["b", 2], ["c", 3]]Step 3: Generic Interfaces and Types
Generics in interfaces create reusable data shape definitions.
// A generic API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}
// Usage with specific types
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
title: string;
body: string;
authorId: string;
}
type UserResponse = ApiResponse<User>;
type PostListResponse = ApiResponse<PaginatedResponse<Post>>;
// The types are fully resolved:
// PostListResponse.data.items[0].title -> stringGeneric Type Aliases
// A generic Result type for error handling (like Rust's Result)
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseJSON<T>(json: string): Result<T> {
try {
const value = JSON.parse(json) as T;
return { ok: true, value };
} catch (error) {
return { ok: false, error: error as Error };
}
}
const result = parseJSON<User>('{"id": "1", "name": "Alice", "email": "alice@example.com"}');
if (result.ok) {
console.log(result.value.name); // TypeScript knows this is a User
} else {
console.error(result.error.message); // TypeScript knows this is an Error
}Step 4: Generic Constraints with extends
Constraints limit which types can be used as a generic parameter, ensuring the generic type has properties your code depends on.
// Without constraint - this fails
function getLength<T>(item: T): number {
// return item.length; // Error: Property 'length' does not exist on type 'T'
return 0;
}
// With constraint - T must have a length property
function getLength<T extends { length: number }>(item: T): number {
return item.length; // Works because T is guaranteed to have 'length'
}
getLength("hello"); // OK: string has .length
getLength([1, 2, 3]); // OK: array has .length
getLength({ length: 10 }); // OK: object has .length
// getLength(42); // Error: number does not have .lengthThe keyof Constraint Pattern
One of the most useful constraint patterns is limiting a generic to the keys of another type.
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const user = { id: "1", name: "Alice", email: "alice@example.com", role: "admin" };
const picked = pick(user, ["name", "email"]);
// type: Pick<typeof user, "name" | "email">
// value: { name: "Alice", email: "alice@example.com" }
// TypeScript prevents picking invalid keys:
// pick(user, ["name", "invalid"]); // ErrorStep 5: Default Type Parameters
Like function parameters, generics can have default values.
interface FetchOptions<TData = unknown, TError = Error> {
url: string;
onSuccess?: (data: TData) => void;
onError?: (error: TError) => void;
retries?: number;
}
// Uses defaults: TData = unknown, TError = Error
function fetchData(options: FetchOptions): void {
// ...
}
// Specifies TData, uses default TError
function fetchUser(options: FetchOptions<User>): void {
// options.onSuccess receives User
}
// Specifies both
interface ApiError {
code: string;
message: string;
details?: Record<string, string>;
}
function fetchWithCustomError(options: FetchOptions<User, ApiError>): void {
// options.onError receives ApiError
}Step 6: Conditional Types
Conditional types select a type based on a condition, enabling type-level logic.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
// Practical example: Extract the return type of a function
type AsyncReturnType<T> = T extends (...args: any[]) => Promise<infer R> ? R : never;
async function getUser(): Promise<User> {
return { id: "1", name: "Alice", email: "alice@example.com" };
}
type UserResult = AsyncReturnType<typeof getUser>; // UserThe infer Keyword
infer lets you extract types from within other types.
// Extract the element type from an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type StringElement = ElementOf<string[]>; // string
type UserElement = ElementOf<User[]>; // User
// Extract event payload type from an event handler
type EventPayload<T> = T extends (event: infer P) => void ? P : never;
type ClickHandler = (event: MouseEvent) => void;
type ClickPayload = EventPayload<ClickHandler>; // MouseEventStep 7: Real-World Patterns
Type-Safe API Client
Build a fetch wrapper that infers response types from endpoint definitions.
interface ApiEndpoints {
"/users": { response: User[]; params: { role?: string } };
"/users/:id": { response: User; params: { id: string } };
"/posts": { response: Post[]; params: { page?: number; limit?: number } };
"/posts/:id": { response: Post; params: { id: string } };
}
async function apiClient<T extends keyof ApiEndpoints>(
endpoint: T,
params?: ApiEndpoints[T]["params"]
): Promise<ApiEndpoints[T]["response"]> {
let url: string = endpoint;
// Replace path parameters
if (params) {
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, String(value));
}
}
const response = await fetch(`/api${url}`);
return response.json();
}
// Fully type-safe usage:
const users = await apiClient("/users", { role: "admin" }); // User[]
const user = await apiClient("/users/:id", { id: "123" }); // User
const posts = await apiClient("/posts", { page: 1 }); // Post[]
// TypeScript catches mistakes:
// await apiClient("/invalid"); // Error: not a valid endpoint
// await apiClient("/users", { invalid: true }); // Error: invalid paramType-Safe Form Handler
Create a form handler that infers field types from a schema definition.
type FieldType = "text" | "number" | "email" | "checkbox";
interface FieldConfig {
type: FieldType;
required?: boolean;
label: string;
}
type InferFieldValue<T extends FieldConfig> = T["type"] extends "checkbox"
? boolean
: T["type"] extends "number"
? number
: string;
type InferFormValues<T extends Record<string, FieldConfig>> = {
[K in keyof T]: InferFieldValue<T[K]>;
};
function createForm<T extends Record<string, FieldConfig>>(
schema: T
): {
schema: T;
validate: (values: InferFormValues<T>) => boolean;
getDefaults: () => InferFormValues<T>;
} {
return {
schema,
validate: (values) => {
for (const [key, config] of Object.entries(schema)) {
if (config.required && !values[key as keyof typeof values]) {
return false;
}
}
return true;
},
getDefaults: () => {
const defaults = {} as InferFormValues<T>;
for (const [key, config] of Object.entries(schema)) {
const k = key as keyof InferFormValues<T>;
if (config.type === "checkbox") {
(defaults as any)[k] = false;
} else if (config.type === "number") {
(defaults as any)[k] = 0;
} else {
(defaults as any)[k] = "";
}
}
return defaults;
},
};
}
// Usage:
const loginForm = createForm({
email: { type: "email", required: true, label: "Email" },
password: { type: "text", required: true, label: "Password" },
rememberMe: { type: "checkbox", label: "Remember me" },
});
const defaults = loginForm.getDefaults();
// type: { email: string; password: string; rememberMe: boolean }
loginForm.validate({
email: "alice@example.com",
password: "secret",
rememberMe: true,
}); // trueGeneric React Component
Build a generic list component that preserves item types.
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
if (items.length === 0) {
return <p>{emptyMessage || "No items"}</p>;
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// Usage - TypeScript infers the item type from the items array
<List
items={users}
keyExtractor={(user) => user.id} // user is typed as User
renderItem={(user) => <span>{user.name}</span>} // user is typed as User
/>Putting It All Together
Generics are the backbone of type-safe, reusable TypeScript code. The progression from basic to advanced follows a clear path:
- ›Generic functions -- write once, use with any type (
getFirst<T>) - ›Generic interfaces -- define reusable data shapes (
ApiResponse<T>) - ›Constraints -- limit generics to types with required properties (
T extends { length: number }) - ›Default types -- provide sensible fallbacks (
FetchOptions<TData = unknown>) - ›Conditional types -- type-level branching and inference (
T extends Promise<infer R> ? R : never) - ›Real-world composition -- combine all patterns for type-safe API clients, form handlers, and component libraries
The key insight is that generics flow type information through your code. When you call apiClient("/users"), TypeScript traces the generic through the endpoint map, resolves the response type, and gives you autocompletion on the result. No runtime cost, full compile-time safety.
Next Steps
- ›Mapped types -- learn
Record,Partial,Required, andReadonlyto transform types systematically - ›Template literal types -- use string manipulation at the type level for route patterns and event names
- ›Recursive types -- define types that reference themselves for trees, nested forms, and JSON schemas
- ›Declaration merging -- extend third-party library types with additional generic parameters
- ›Variance annotations -- use
inandoutkeywords to control generic covariance and contravariance
FAQ
What are TypeScript generics and why do they matter?
Generics are type parameters that let you write functions and classes that work with multiple types while preserving type safety. Without them, you would either duplicate code for each type or lose type information by using any.
When should you use generics vs union types?
Use generics when the output type depends on the input type (like a function that returns the same type it receives). Use union types when a value can be one of a fixed set of known types.
How do generic constraints work in TypeScript?
Generic constraints use the extends keyword to limit what types can be passed as a generic parameter, ensuring the generic type has certain properties or capabilities that your code depends on.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.