Loading…
Loading…
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
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.
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 hereGenerics 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"]);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 assignableFunctions 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]]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 -> string// 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
}extendsConstraints 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 .lengthkeyof Constraint PatternOne 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"]); // ErrorLike 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
}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>; // Userinfer Keywordinfer 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>; // MouseEventBuild 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 paramCreate 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,
}); // trueBuild 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
/>Generics are the backbone of type-safe, reusable TypeScript code. The progression from basic to advanced follows a clear path:
getFirst<T>)ApiResponse<T>)T extends { length: number })FetchOptions<TData = unknown>)T extends Promise<infer R> ? R : never)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.
Record, Partial, Required, and Readonly to transform types systematicallyin and out keywords to control generic covariance and contravarianceGenerics 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.
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.
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
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.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
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.
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.