Generics in Depth: Constraints, Defaults, Type-Safe fetch
<T> Is a Relationship, Not a Placeholder
The common beginner mental model — "T is just a blank you can fill with any type" — works for trivial cases but breaks down quickly. The accurate mental model: <T> describes the relationship between what the caller provides and how the function behaves internally.
// Wrong mental model: "this function accepts any value because T is anything"
function identity<T>(value: T): T {
return value;
}
// Right mental model: T is inferred at the call site to a concrete type,
// and the return type is locked to that same concrete type.
const s = identity("hello"); // T = string, returns string
const n = identity(42); // T = number, returns number
// This is what generics are for: constraining input-output relationships
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const x = first([1, 2, 3]); // T = number, x: number | undefined
const y = first(["a", "b"]); // T = string, y: string | undefined
extends Constraints
An unconstrained <T> knows nothing about T. The compiler won't let you access any property or call any method on it. The extends constraint tells the compiler what T is guaranteed to have.
// Anti-pattern: unconstrained T, compiler blocks property access
function getLength<T>(value: T): number {
return value.length; // Error: Property 'length' does not exist on type 'T'
}
// Correct: constrain T to have a length property
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // OK — strings have length
getLength([1, 2, 3]); // OK — arrays have length
getLength(42); // Error: number does not satisfy the constraint
<T extends keyof U> — Property Name Constraints
This is one of the most used generic constraint patterns in TypeScript:
// Unsafe version — loses all type information
function getProp(obj: object, key: string): unknown {
return (obj as any)[key];
}
// Type-safe version — return type is precisely T[K]
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30, active: true };
const name = getProp(user, "name"); // type: string
const age = getProp(user, "age"); // type: number
getProp(user, "email"); // Error: 'email' does not exist in type of user
Object Constraints
// T extends object prevents primitives from being passed
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
const result = merge({ x: 1 }, { y: "hello" });
// result: { x: number; y: string }
merge(1, { y: 2 }); // Error: number does not satisfy 'extends object'
Multiple Type Parameters
Multiple type parameters can reference each other to encode richer relationships:
// K, V independent: straightforward key-value mapping
function mapKeys<K extends string, V>(
keys: K[],
getValue: (key: K) => V
): Record<K, V> {
return Object.fromEntries(keys.map(k => [k, getValue(k)])) as Record<K, V>;
}
const scores = mapKeys(["alice", "bob"], name => name.length);
// type: Record<"alice" | "bob", number>
// U extends keyof T: the second parameter must be a subset of T's keys
function pick<T, U extends keyof T>(obj: T, keys: U[]): Pick<T, U> {
const result = {} as Pick<T, U>;
keys.forEach(k => { result[k] = obj[k]; });
return result;
}
const user = { id: 1, name: "Alice", email: "[email protected]", role: "admin" };
const safe = pick(user, ["id", "name"]); // { id: number; name: string }
Generic Defaults: <T = string>
When the caller doesn't supply a type argument, the default kicks in:
// Without default: T inferred as unknown when not specified
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// With default: T = string when omitted
interface ApiResponse<T = string> {
data: T;
status: number;
message: string;
}
// Uses default
const r1: ApiResponse = { data: "OK", status: 200, message: "" };
// Explicit override
const r2: ApiResponse<{ id: number }> = { data: { id: 1 }, status: 200, message: "" };
// Generic defaults on functions
function createList<T = string>(): T[] {
return [];
}
const strings = createList(); // T = string, type: string[]
const numbers = createList<number>(); // explicit, type: number[]
Generic Interfaces and Classes
Generic Interfaces
// A generic repository pattern
interface Repository<T> {
findById(id: number): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: number): Promise<void>;
}
interface User {
id: number;
name: string;
email: string;
}
// Bind the concrete type at implementation time
class UserRepository implements Repository<User> {
private users: User[] = [];
async findById(id: number): Promise<User | null> {
return this.users.find(u => u.id === id) ?? null;
}
async findAll(): Promise<User[]> {
return [...this.users];
}
async save(user: User): Promise<User> {
const idx = this.users.findIndex(u => u.id === user.id);
if (idx >= 0) this.users[idx] = user;
else this.users.push(user);
return user;
}
async delete(id: number): Promise<void> {
this.users = this.users.filter(u => u.id !== id);
}
}
Generic Classes
// A type-safe LIFO stack
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop(); // type: number | undefined
const strStack = new Stack<string>();
strStack.push("hello");
strStack.push(42); // Error: Argument of type 'number' is not assignable to 'string'
keyof and typeof Combined with Generics
// keyof extracts union of a type's keys
type UserKeys = keyof User; // "id" | "name" | "email"
// typeof infers a type from a value
const defaultConfig = {
timeout: 5000,
retries: 3,
baseUrl: "https://api.example.com",
};
type Config = typeof defaultConfig;
// { timeout: number; retries: number; baseUrl: string }
// Combine with generics: runtime-safe property access with fallback
function getRequired<T, K extends keyof T>(
obj: T,
key: K,
fallback: NonNullable<T[K]>
): NonNullable<T[K]> {
const val = obj[key];
return (val != null ? val : fallback) as NonNullable<T[K]>;
}
interface Config {
timeout?: number;
host?: string;
}
const cfg: Config = { timeout: 3000 };
const timeout = getRequired(cfg, "timeout", 5000); // number, not undefined
const host = getRequired(cfg, "host", "localhost"); // string, not undefined
Main Project: Building a Type-Safe fetch Wrapper
Step 1: The Minimal Version
async function api<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json() as Promise<T>;
}
// Usage: caller declares the expected shape
interface Post {
id: number;
title: string;
body: string;
}
const post = await api<Post>("/api/posts/1");
post.title; // type: string, full IntelliSense available
Step 2: Structured Error Handling
class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public body: unknown
) {
super(`HTTP ${status}: ${statusText}`);
this.name = "ApiError";
}
}
async function api<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
let body: unknown;
try { body = await res.json(); } catch { body = null; }
throw new ApiError(res.status, res.statusText, body);
}
return res.json() as Promise<T>;
}
Step 3: Base URL, Default Headers, and Timeout
interface ApiClientOptions {
baseUrl: string;
headers?: Record<string, string>;
timeout?: number;
}
function createApiClient(clientOptions: ApiClientOptions) {
const { baseUrl, headers: defaultHeaders = {}, timeout = 10000 } = clientOptions;
async function request<T>(
path: string,
options: RequestInit & { params?: Record<string, string> } = {}
): Promise<T> {
const { params, headers: reqHeaders, ...fetchOptions } = options;
const url = new URL(path, baseUrl);
if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url.toString(), {
...fetchOptions,
headers: { ...defaultHeaders, ...reqHeaders as Record<string, string> },
signal: controller.signal,
});
if (!res.ok) {
let body: unknown;
try { body = await res.json(); } catch { body = null; }
throw new ApiError(res.status, res.statusText, body);
}
return res.json() as Promise<T>;
} finally {
clearTimeout(timer);
}
}
return {
get<T>(path: string, params?: Record<string, string>): Promise<T> {
return request<T>(path, { method: "GET", params });
},
post<T>(path: string, body: unknown): Promise<T> {
return request<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
},
put<T>(path: string, body: unknown): Promise<T> {
return request<T>(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
},
delete<T>(path: string): Promise<T> {
return request<T>(path, { method: "DELETE" });
},
};
}
// Usage
const client = createApiClient({
baseUrl: "https://api.example.com",
headers: { Authorization: "Bearer token123" },
});
interface User { id: number; name: string; email: string; }
interface Post { id: number; title: string; userId: number; }
const user = await client.get<User>("/users/1");
const posts = await client.get<Post[]>("/posts", { userId: "1" });
const newUser = await client.post<User>("/users", { name: "Bob", email: "[email protected]" });
Generic Constraints for DOM Manipulation
// T extends HTMLElement: rejects plain objects, accepts only DOM elements
function querySelector<T extends HTMLElement>(
selector: string,
container: Document | Element = document
): T {
const el = container.querySelector<T>(selector);
if (!el) throw new Error(`Element not found: ${selector}`);
return el;
}
const input = querySelector<HTMLInputElement>("#email");
input.value; // type: string — HTMLInputElement-specific
const canvas = querySelector<HTMLCanvasElement>("#chart");
canvas.getContext("2d"); // type: CanvasRenderingContext2D | null
// Generics + event listener with full event type inference
function addListener<K extends keyof HTMLElementEventMap>(
el: HTMLElement,
type: K,
handler: (ev: HTMLElementEventMap[K]) => void
): () => void {
el.addEventListener(type, handler as EventListener);
return () => el.removeEventListener(type, handler as EventListener);
}
const btn = querySelector<HTMLButtonElement>("#submit");
const removeListener = addListener(btn, "click", (ev) => {
ev.preventDefault(); // ev: MouseEvent — fully typed
});
Anti-Pattern: Over-Genericifying
// Anti-pattern: using a generic where a union type is clearer
function format<T extends "json" | "xml" | "csv">(data: unknown, type: T): string {
// T provides no additional constraint power here
if (type === "json") return JSON.stringify(data);
if (type === "xml") return `<data>${data}</data>`;
return String(data);
}
// Correct: a union type is simpler and equally precise
type FormatType = "json" | "xml" | "csv";
function format(data: unknown, type: FormatType): string {
if (type === "json") return JSON.stringify(data);
if (type === "xml") return `<data>${data}</data>`;
return String(data);
}
// Anti-pattern: unrelated parameters stuffed into type parameters
function processAll<A, B, C>(a: A, b: B, c: C): [A, B, C] {
return [a, b, c]; // A, B, C share no relationship — meaningless generics
}
// Correct: only use generics when there IS a type relationship
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second]; // callers get precisely typed tuples back
}
The test for whether you need generics:
- Is there a type relationship between inputs and outputs? → Use generics
- Is there a type relationship between different parameters? → Use generics
- You just want to accept "any type" with no relationship? → Use
unknownor a union type
Summary
| Feature | Syntax | Typical Use |
|---|---|---|
| Basic generic | <T> |
Identity functions, container types |
| Object constraint | <T extends object> |
merge, deep clone |
| Property constraint | <K extends keyof T> |
Safe property access |
| Multi-param constraint | <T, U extends T> |
Subset relationships |
| Generic default | <T = string> |
API response interfaces |
| keyof + generic | <T, K extends keyof T>(obj: T, key: K): T[K] |
Type-safe property reads |
| DOM generic | <T extends HTMLElement> |
querySelector wrappers |
Next chapter: dissecting the source code of TypeScript's built-in utility types — how Partial, Pick, Omit, and Record are actually implemented.