Generics and Constraints
Functions That Work With Any Type — Safely
Without generics, you face a choice: write a function for every type (repetitive), or use any (unsafe). Generics give you both — a single function that works with any type while preserving exact type information through the call.
Think of generics as fill-in-the-blank types. When you write function identity<T>(value: T): T, the T is a blank. The caller fills in the blank when they call the function: identity<string>("hello") fills T = string. But the genius is that TypeScript can usually fill in the blank automatically from the argument: identity("hello") — TypeScript sees a string argument and infers T = string. The blank gets filled, and the return type is string, not any.
Generic Functions
// Without generics — loses type information
function firstElement(arr: any[]): any {
return arr[0];
}
const x = firstElement([1, 2, 3]); // x: any — type information lost
// With generics — preserves type information
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const x = firstElement([1, 2, 3]); // x: number | undefined
const y = firstElement(["a", "b"]); // y: string | undefined
const z = firstElement<boolean>([]); // z: boolean | undefined (explicit)
Multiple type parameters:
function zip<A, B>(a: A[], b: B[]): [A, B][] {
const result: [A, B][] = [];
for (let i = 0; i < Math.min(a.length, b.length); i++) {
result.push([a[i], b[i]]);
}
return result;
}
const pairs = zip([1, 2, 3], ["a", "b", "c"]);
// pairs: [number, string][]
Constraints with extends
Unconstrained generics accept anything. Constraints limit T to types that match a specific shape:
// Unconstrained — T could be anything, can't access .length
function logLength<T>(value: T) {
console.log(value.length); // ERROR: Property 'length' does not exist on type 'T'
}
// Constrained — T must have a length property
function logLength<T extends { length: number }>(value: T) {
console.log(value.length); // OK — T is guaranteed to have length
}
logLength("hello"); // OK — string has length
logLength([1, 2, 3]); // OK — array has length
logLength({ length: 10 }); // OK — has length property
logLength(42); // ERROR — number doesn't have length
keyof Constraint — Type-Safe Property Access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30, email: "alice@example.com" };
getProperty(user, "name"); // Returns string
getProperty(user, "age"); // Returns number
getProperty(user, "foo"); // ERROR: Argument '"foo"' is not assignable to '"name" | "age" | "email"'
How T[K] (indexed access types) work
T[K] is an indexed access type — it looks up the type of property K on type T, just like obj[key] looks up the value at runtime. When T is { name: string; age: number } and K is "name", then T[K] resolves to string.
When K is a union like "name" | "age", T[K] distributes and becomes string | number. This is why keyof T gives you the union of all property name types, and T[keyof T] gives you the union of all property value types.
type User = { name: string; age: number; active: boolean };
type Keys = keyof User; // "name" | "age" | "active"
type Values = User[keyof User]; // string | number | boolean
type NameType = User["name"]; // stringGeneric Interfaces and Types
// Generic interface
interface ApiResponse<T> {
data: T;
status: number;
timestamp: Date;
}
const userResponse: ApiResponse<User> = {
data: { name: "Alice", age: 30 },
status: 200,
timestamp: new Date(),
};
// Generic type alias
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
// Generic with multiple constraints
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
Default Type Parameters
Like default function parameters, type parameters can have defaults:
// Default type parameter
type Container<T = string> = {
value: T;
metadata: Record<string, unknown>;
};
const stringContainer: Container = { value: "hello", metadata: {} }; // T defaults to string
const numberContainer: Container<number> = { value: 42, metadata: {} };
// Practical example: event emitter
type EventMap = Record<string, unknown>;
class Emitter<Events extends EventMap = Record<string, never>> {
private handlers = new Map<string, Function[]>();
on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void) {
const list = this.handlers.get(event as string) ?? [];
list.push(handler);
this.handlers.set(event as string, list);
}
emit<K extends keyof Events>(event: K, data: Events[K]) {
this.handlers.get(event as string)?.forEach(fn => fn(data));
}
}
// Usage with custom events
interface AppEvents {
userLogin: { userId: string };
pageView: { path: string };
error: { message: string; code: number };
}
const emitter = new Emitter<AppEvents>();
emitter.on("userLogin", (data) => {
console.log(data.userId); // data: { userId: string } — fully typed
});
emitter.emit("error", { message: "fail", code: 500 }); // Type-safe
emitter.emit("error", { message: "fail" }); // ERROR: missing 'code'
Production Scenario: Type-Safe Data Fetching Hook
interface UseFetchOptions<T> {
url: string;
transform?: (raw: unknown) => T;
enabled?: boolean;
}
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useFetch<T>(options: UseFetchOptions<T>): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: "idle" });
useEffect(() => {
if (options.enabled === false) return;
setState({ status: "loading" });
fetch(options.url)
.then(res => res.json())
.then(raw => {
const data = options.transform ? options.transform(raw) : (raw as T);
setState({ status: "success", data });
})
.catch(error => {
setState({ status: "error", error });
});
}, [options.url, options.enabled]);
return state;
}
// Caller gets exact types
const userState = useFetch<User>({
url: "/api/user/1",
transform: (raw) => userSchema.parse(raw), // Zod validation
});
if (userState.status === "success") {
userState.data.name; // data: User — fully typed
}
-
Wrong: Using generics where a concrete type suffices:
function greet<T extends string>(name: T)Right: Use the concrete type directly:function greet(name: string) -
Wrong: Constraining with T extends any or T extends unknown Right: Omit the constraint — unconstrained T already accepts everything
-
Wrong: Using T extends string | number when only string methods are called Right: Constrain to the minimum: T extends string if you call string methods
-
Wrong: Forgetting that inferred T is the exact type, not the constraint Right:
T extends { length: number }but T preserves the full type — string, array, etc.
Build a Type-Safe Event Emitter
- 1Generics preserve type information through function calls — use them when input types determine output types
- 2Constrain generics with extends to the minimum shape needed —
T extends { length: number }not T extends any - 3T infers to the exact argument type, not the constraint — the constraint is a minimum, not a ceiling
- 4Use default type parameters for commonly-used generic types:
type Result<T, E = Error> - 5Don't use generics where a concrete type works — generics add complexity that should be justified by real polymorphism