On this page
Generics from scratch
Why generics exist
Consider a simple function that returns its input unchanged — the identity function:
function identity(value: string): string {
return value;
}This works for strings. But what about numbers? Booleans? Arrays? You could write a separate function for each type, but that is unscalable. You could use any to accept everything:
function identity(value: any): any {
return value;
}
const result = identity(42); // result is any — type information lost
result.toFixed(2); // TypeScript accepts this even if result were a stringUsing any solves the code duplication problem but destroys type safety. The return type is any, so TypeScript no longer knows what type you actually get back.
Generics solve both problems. They let you write one function that works for any type while preserving full type information:
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // T is string → return type is string
const num = identity(42); // T is number → return type is number
const arr = identity([1, 2, 3]); // T is number[] → return type is number[]The <T> is a type parameter — a placeholder that TypeScript fills in at each call site based on the provided argument.
How TypeScript infers type arguments
When you call a generic function, TypeScript usually infers the type argument from the provided value — you rarely need to specify it explicitly:
function wrap<T>(value: T): { wrapped: T } {
return { wrapped: value };
}
// TypeScript infers T = string from the argument
const result = wrap("hello");
// result: { wrapped: string }
// You can also specify T explicitly if inference fails or is ambiguous
const explicit = wrap<number[]>([1, 2, 3]);TypeScript performs this inference at compile time with zero runtime overhead.
Multiple type parameters
Functions can have more than one type parameter. Separate them with commas inside the angle brackets:
function zip<A, B>(arrA: A[], arrB: B[]): [A, B][] {
const length = Math.min(arrA.length, arrB.length);
return Array.from({ length }, (_, i) => [arrA[i], arrB[i]]);
}
const zipped = zip(["a", "b", "c"], [1, 2, 3]);
// inferred: [string, number][]
// result: [["a", 1], ["b", 2], ["c", 3]]A two-parameter example that maps key-value pairs:
function mapValues<K extends string, V, R>(
record: Record<K, V>,
transform: (value: V, key: K) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in record) {
result[key] = transform(record[key], key);
}
return result;
}
const prices = { apple: 1.5, banana: 0.5, cherry: 2.0 };
const rounded = mapValues(prices, (price) => Math.round(price));
// rounded: { apple: 2, banana: 1, cherry: 2 }Generic interfaces
Interfaces can be generic. A generic interface describes a structure that is parameterised by one or more types:
interface Paginated<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
// Used with any item type
type UserPage = Paginated<User>;
type ProductPage = Paginated<Product>;
type OrderPage = Paginated<Order>;The Paginated<T> interface captures the pagination metadata structure once and reuses it for any data type. When you write a function that returns Paginated<T>, callers know exactly what fields to expect and TypeScript verifies every access.
Generic interfaces for services and repositories
The Repository pattern is a classic example of generics in practice:
interface CrudService<TEntity, TId = string> {
create(data: Omit<TEntity, "id">): Promise<TEntity>;
readById(id: TId): Promise<TEntity | null>;
readAll(filter?: Partial<TEntity>): Promise<TEntity[]>;
update(id: TId, data: Partial<TEntity>): Promise<TEntity>;
remove(id: TId): Promise<void>;
}Any class that implements CrudService<User> must provide all five methods with the correct signatures for User. If a method has the wrong parameter type or return type, TypeScript reports an error immediately.
Generic classes
Classes can also be generic. The type parameter is available in all instance methods and properties:
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 numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push("three"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
const top = stringStack.peek(); // inferred: string | undefinedA more advanced example — a typed event emitter:
class EventBus<TEvents extends Record<string, unknown>> {
private handlers = new Map<keyof TEvents, Set<Function>>();
on<K extends keyof TEvents>(event: K, handler: (data: TEvents[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
}
// Define the event map
interface AppEvents {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"order:placed": { orderId: string; total: number };
}
const bus = new EventBus<AppEvents>();
bus.on("user:login", ({ userId, timestamp }) => {
// TypeScript knows userId is string and timestamp is Date
console.log(`User ${userId} logged in at ${timestamp.toISOString()}`);
});
bus.emit("user:login", { userId: "abc", timestamp: new Date() }); // OK
bus.emit("user:login", { userId: "abc" }); // Error: Property 'timestamp' is missingDefault type parameters
Type parameters can have default values, just like function parameters:
// T defaults to unknown when not specified
interface ApiResult<T = unknown> {
data: T;
status: number;
headers: Record<string, string>;
}
// Using the default — T is unknown
const raw: ApiResult = { data: JSON.parse("{}"), status: 200, headers: {} };
// Specifying T explicitly
const typed: ApiResult<User[]> = { data: [], status: 200, headers: {} };Defaults are especially useful in library code where some callers know the exact type and others do not.
Constraints with extends
Sometimes you need to restrict what types can be passed as a type argument. Use extends to add a constraint:
// T must have an id property
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
const users: User[] = [{ id: "1", name: "Alice", email: "[email protected]" }];
const found = findById(users, "1"); // inferred: User | undefined
// keyof constraint — K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "1", name: "Alice", age: 30 };
const name = getProperty(user, "name"); // inferred: string
const age = getProperty(user, "age"); // inferred: number
const bad = getProperty(user, "email"); // Error: '"email"' is not a key of the objectThe keyof operator returns the union of all property keys of a type as string literals. Combined with an extends constraint, it enables precise property access typing.
Practice
- Implement a generic
Result<T, E = Error>type that represents either a successful value ({ ok: true; value: T }) or a failure ({ ok: false; error: E }). Write helper functionssuccess<T>(value: T): Result<T>andfailure<E>(error: E): Result<never, E>. - Write a generic class
Queue<T>withenqueue,dequeue,peek, andisEmptymethods. Instantiate it with different types and verify TypeScript enforces the element type. - Implement a generic
groupBy<T, K extends string>(items: T[], keyFn: (item: T) => K): Record<K, T[]>function. Test it with an array of users grouped by their role.
// Without generics — you must choose: lose type info or duplicate code
function identityAny(value: any): any {
return value; // return type is any — useless for type safety
}
function identityString(value: string): string {
return value;
}
function identityNumber(value: number): number {
return value;
}
// ... one function per type — does not scale
// ✅ With a generic type parameter — one function, full type safety
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // inferred: string
const num = identity(42); // inferred: number
const arr = identity([1, 2, 3]); // inferred: number[]
Sign in to track your progress