Generics Introduction
Generics are TypeScript's mechanism for writing code that works with many types while still being fully type-safe. Instead of committing to a specific type upfront, you write a type parameter — a placeholder that gets filled in when the code is used. Generics let you build reusable, flexible abstractions without sacrificing any type information.
The Problem Generics Solve
Without generics, you face an impossible choice: write identical functions for every type, or give up type safety by using any.
// Approach 1: Duplicate for every type (tedious)
function firstNumber(arr: number[]): number | undefined { return arr[0]; }
function firstString(arr: string[]): string | undefined { return arr[0]; }
function firstBoolean(arr: boolean[]): boolean | undefined { return arr[0]; }
// Approach 2: Use any (no type safety — might as well use JavaScript)
function first(arr: any[]): any { return arr[0]; }
const val = first([1, 2, 3]);
val.toUpperCase(); // No error at compile time — but crashes at runtime!
// Approach 3: Generics — write once, type-safe always
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // T inferred as number → number | undefined
const str = first(["a", "b"]); // T inferred as string → string | undefined
num?.toFixed(2); // OK — TypeScript knows num is number
str?.toUpperCase(); // OK — TypeScript knows str is stringany with the precision of specific types. TypeScript infers the type argument at the call site so you rarely need to supply it explicitly.Type Parameter Syntax
Type parameters are declared inside angle brackets (<>) immediately after the function name (or class name, or type alias name). By convention, single letters like T, U, K, V are used for generic parameters, but any valid identifier works.
// Single type parameter
function identity<T>(value: T): T {
return value;
}
// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
// More descriptive names (equally valid)
function transform<Input, Output>(
value: Input,
fn: (input: Input) => Output,
): Output {
return fn(value);
}
// Usage — TypeScript infers all type parameters
const p = pair("hello", 42); // [string, number]
const n = transform("123", Number); // numberExplicit vs. Inferred Type Arguments
TypeScript usually infers type arguments from the values you pass. You can supply them explicitly when inference is ambiguous or when you want to lock in a specific type.
function createArray<T>(item: T, length: number): T[] {
return Array.from({ length }, () => item);
}
// Inferred — TypeScript figures out T = string
const strs = createArray("x", 5); // string[]
// Explicit — you tell TypeScript T = number
const nums = createArray<number>(0, 10); // number[]
// Sometimes inference needs help
function merge<T, U>(a: T, b: U): T & U {
return { ...a, ...b } as T & U;
}
// Inferred from arguments
const result = merge({ name: "Alice" }, { age: 30 });
// result: { name: string } & { age: number } → { name: string; age: number }createArray<string | number>("x", 3) produces (string | number)[] instead of string[].Generic Interfaces
Interfaces can be generic too, letting you describe data structures that work across types.
// A generic container
interface Box<T> {
value: T;
label: string;
}
const stringBox: Box<string> = { value: "hello", label: "greeting" };
const numberBox: Box<number> = { value: 42, label: "answer" };
// Generic response wrapper (extremely common in real codebases)
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
interface User {
id: number;
name: string;
email: string;
}
// Using the generic response for a specific payload
type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;
function handleResponse(res: UserResponse) {
console.log(res.data.name); // fully typed
console.log(res.status); // number
}Generic Type Aliases
// Generic type alias
type Maybe<T> = T | null | undefined;
type MaybeString = Maybe<string>; // string | null | undefined
type MaybeNumber = Maybe<number>; // number | null | undefined
// Generic Result type (a poor man's Rust Result<T, E>)
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number> {
if (b === 0) return { ok: false, error: new Error("Division by zero") };
return { ok: true, value: a / b };
}
const r = divide(10, 2);
if (r.ok) {
console.log(r.value); // number
} else {
console.error(r.error.message);
}
// Generic Pair
type Pair<T, U = T> = [T, U]; // U defaults to T
type NumberPair = Pair<number>; // [number, number]
type MixedPair = Pair<string, boolean>; // [string, boolean]Generic Constraints
By default, a generic type parameter T can be anything. Use extends to constrain T to a subset of types, unlocking properties and methods of the constraint inside the function.
// Without constraint — T is unknown inside the function
function getLength<T>(value: T): number {
// return value.length; // Error — T might not have .length
return 0;
}
// With constraint — T must have a 'length' property
function getLength<T extends { length: number }>(value: T): number {
return value.length; // OK — guaranteed by the constraint
}
getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength({ length: 10 }); // 10
// getLength(42); // Error — number has no 'length'
// Constraint to an object type with a specific key
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice", active: true };
const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // number
// getProperty(user, "missing"); // Error — not a key of userK extends keyof T is one of the most powerful constraint combinations in TypeScript. It guarantees that K is a valid key of T, making the return type T[K] fully precise.Generic Classes
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(); // number | undefined
const strStack = new Stack<string>();
strStack.push("hello");
strStack.push("world");
const word = strStack.peek(); // string | undefinedDefault Type Parameters
Like function parameters, type parameters can have default values with = DefaultType.
// E defaults to Error if not supplied
interface Result<T, E = Error> {
data?: T;
error?: E;
}
const ok: Result<User> = { data: { id: 1, name: "Alice", email: "a@b.com" } };
const fail: Result<User, string> = { error: "Not found" };
// Generic component props with defaults
interface ListProps<T = string> {
items: T[];
renderItem: (item: T) => string;
}
const stringList: ListProps = {
items: ["a", "b"],
renderItem: (item) => item,
};
const numberList: ListProps<number> = {
items: [1, 2, 3],
renderItem: (n) => n.toFixed(2),
};Common Generic Utilities You Will Write
// groupBy — group array items by a key
function groupBy<T, K extends PropertyKey>(
items: T[],
keySelector: (item: T) => K,
): Record<K, T[]> {
return items.reduce(
(acc, item) => {
const key = keySelector(item);
(acc[key] ??= []).push(item);
return acc;
},
{} as Record<K, T[]>,
);
}
const users = [
{ id: 1, role: "admin", name: "Alice" },
{ id: 2, role: "editor", name: "Bob" },
{ id: 3, role: "admin", name: "Carol" },
];
const byRole = groupBy(users, (u) => u.role);
// byRole.admin → [Alice, Carol]
// byRole.editor → [Bob]
// keyBy — convert array to object keyed by a property
function keyBy<T, K extends PropertyKey>(
items: T[],
keySelector: (item: T) => K,
): Record<K, T> {
return items.reduce(
(acc, item) => {
acc[keySelector(item)] = item;
return acc;
},
{} as Record<K, T>,
);
}
const userMap = keyBy(users, (u) => u.id);
// userMap[1] → { id: 1, role: "admin", name: "Alice" }Generics vs. any vs. unknown
Feature | any | unknown | Generic T |
|---|---|---|---|
Type-safe | No | Yes (after narrowing) | Yes — always |
Preserves input type | No | No | Yes |
Can use methods/props | Yes (unsafe) | No (must narrow first) | Yes (if constrained) |
Inferred at call site | N/A | N/A | Yes |
Good for reuse | Poor | Poor | Excellent |
Variance: Covariance and Contravariance
Generic types have a variance relationship to their type parameters — this determines when one generic type is assignable to another. Understanding variance helps you reason about generic function compatibility.
// Covariant: if Dog extends Animal, then Box<Dog> is assignable to Box<Animal>
// (output/producer position — returning T)
type Producer<T> = () => T;
const dogProducer: Producer<Dog> = () => new Dog();
const animalProducer: Producer<Animal> = dogProducer; // OK — covariant
// Contravariant: Consumer<Animal> is assignable to Consumer<Dog>
// (input/consumer position — accepting T)
type Consumer<T> = (value: T) => void;
const animalConsumer: Consumer<Animal> = (a) => console.log(a.name);
const dogConsumer: Consumer<Dog> = animalConsumer; // OK — contravariant
// Invariant: both input and output — no substitution is safe
type Transform<T> = (value: T) => T;
// Transform<Dog> is NOT assignable to Transform<Animal> and vice versa
// TypeScript 4.7+ allows explicit variance annotations
type CovariantBox<out T> = { get: () => T };
type ContravariantSink<in T> = { consume: (value: T) => void };out T and in T are useful in complex generic hierarchies where TypeScript's inference is conservative.Generics in React (Brief Preview)
// Generic component props
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (item: T) => string;
}
// The component is generic — works for any option type
function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
return (
<select
value={String(value)}
onChange={(e) => {
const selected = options.find((o) => String(o) === e.target.value);
if (selected !== undefined) onChange(selected);
}}
>
{options.map((o, i) => (
<option key={i} value={String(o)}>{getLabel(o)}</option>
))}
</select>
);
}
// Usage with different types — T is inferred from props
type Status = "active" | "inactive" | "pending";
<Select
options={["active", "inactive", "pending"] as Status[]}
value={"active"}
onChange={(v) => console.log(v)} // v: Status
getLabel={(s) => s.toUpperCase()}
/>Summary
Generics let you write reusable, type-safe code without committing to a specific type
Type parameters are declared with angle brackets after the function, class, or type alias name
TypeScript infers type arguments from call-site values — explicit annotation is rarely needed
Generic interfaces and type aliases describe reusable data shapes like Box<T> and Result<T, E>
Constraints (extends) restrict a type parameter and unlock the constrained type inside the body
K extends keyof T is the key pattern for safe property access and lookup functions
Default type parameters provide fallbacks when no type argument is given
Variance determines when one generic type is assignable to another (covariant, contravariant, invariant)