
Mykola Nesterchuk - March 4, 2026
Generics are one of those things in TypeScript that look scary at first - angle brackets everywhere, mysterious Ts and Us floating around - but once it finally clicks, going back feels impossible.
The idea is simple: write code that works with any type without throwing type safety out the window. Think of it as a variable, but for types instead of values.
Say you want a function that returns the first item of an array. Without generics you're stuck choosing between two bad options:
// too loose - you lose all type information function first(arr: any[]): any { return arr[0]; } // too rigid - only works for numbers function first(arr: number[]): number { return arr[0]; }
Generics let you have it both ways:
function first<T>(arr: T[]): T { return arr[0]; } const num = first([1, 2, 3]); // number const str = first(['a', 'b', 'c']); // string
T is just a placeholder. TypeScript figures out what it should be from whatever you pass in.
Generics work in interfaces too, which comes in handy when you're describing shapes that wrap different kinds of data:
interface ApiResponse<T> { data: T; status: number; message: string; } interface User { id: number; name: string; } interface Post { slug: string; title: string; } const userResponse: ApiResponse<User> = { data: { id: 1, name: 'Mykola' }, status: 200, message: 'OK', }; const postResponse: ApiResponse<Post> = { data: { slug: 'typescript-generics', title: 'TypeScript Generics Explained' }, status: 200, message: 'OK', };
You'll see this pattern constantly when working with APIs.
Sometimes you want to accept any type, but only if it has certain properties. That's what extends is for:
// TypeScript has no idea T has a .length - this breaks function logLength<T>(value: T): void { console.log(value.length); } // now it knows function logLength<T extends { length: number }>(value: T): void { console.log(value.length); } logLength('hello'); // fine, strings have .length logLength([1, 2, 3]); // fine, arrays too logLength(42); // nope
One of the more useful patterns - reading a property from an object without guessing:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: 1, name: 'Mykola', role: 'admin' }; const name = getProperty(user, 'name'); // string const id = getProperty(user, 'id'); // number const bad = getProperty(user, 'email'); // compile error, doesn't exist
K extends keyof T means K can only be one of the actual keys of T. Whole category of bugs gone.
Classes can be generic too. A stack is the classic example:
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; } } const numStack = new Stack<number>(); numStack.push(1); numStack.push(2); console.log(numStack.pop()); // 2 const strStack = new Stack<string>(); strStack.push('hello'); strStack.push(42); // compile error
Nothing stops you from using more than one. Useful when input and output are different types:
function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair('age', 30); // [string, number]
Or when mapping one type to another:
function map<T, U>(arr: T[], transform: (item: T) => U): U[] { return arr.map(transform); } const lengths = map(['hello', 'world', 'ts'], s => s.length); // number[]
Type parameters can have defaults, same as function arguments:
interface PaginatedResponse<T, E = string> { items: T[]; total: number; error?: E; } // E defaults to string const posts: PaginatedResponse<Post> = { items: [], total: 0, }; // override it with something more specific interface ApiError { code: number; message: string; } const users: PaginatedResponse<User, ApiError> = { items: [], total: 0, error: { code: 404, message: 'Not found' }, };
Here's something you can actually drop into a project:
async function fetchJson<T>(url: string): Promise<T> { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.json() as Promise<T>; } const user = await fetchJson<User>('/api/users/1'); console.log(user.name); // TypeScript knows it's a string const posts = await fetchJson<Post[]>('/api/posts'); posts.forEach(post => console.log(post.slug));
Once generics make sense, you start seeing places for them everywhere:
<T> - a type placeholder filled in when the function is calledextends - limit which types are allowedkeyof - limit to actual keys of another type<T, U> - when input and output types differ<T = string> - default type for when nothing is passedA good rule of thumb: every time you reach for any, or find yourself writing nearly identical functions for different types, there's probably a generic waiting to be written.