Generics in TypeScript: Writing Reusable, Type-Safe Code
The moment you write the same function twice with only the type changed โ once for string[], once for number[] โ is the moment you need a generic. Generics let a function, class, or type parameter over a type the same way a regular parameter parameters over a value: you donโt fix the type at the point you write the code, you fix it at the point you use it, and TypeScript checks everything consistently either way.
The Problem Generics Solve
// Without generics โ either lose type safety, or duplicate the function per typefunction firstStringElement(arr: string[]): string { return arr[0];}function firstNumberElement(arr: number[]): number { return arr[0];}
// Or give up type safety entirely:function firstElementAny(arr: any[]): any { return arr[0]; // works for any array, but you get zero type information back}// With a genericfunction firstElement<T>(arr: T[]): T { return arr[0];}
const firstNum = firstElement([1, 2, 3]); // inferred: T is number, return type is numberconst firstStr = firstElement(["a", "b", "c"]); // inferred: T is string, return type is string<T> declares a type parameter โ a placeholder that gets filled in with a concrete type at each call site, inferred automatically from the argument you actually pass. Unlike the any version, the generic version preserves the connection between input and output: pass in a number[], get back a number, fully type-checked, with zero duplication.
Generic Functions With Multiple Type Parameters
function pair<A, B>(first: A, second: B): [A, B] { return [first, second];}
pair("age", 30); // [string, number]pair(true, [1, 2, 3]); // [boolean, number[]]// A practical example: a type-safe object property getterfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}
const user = { name: "Alice", age: 30 };getProperty(user, "name"); // typed as stringgetProperty(user, "age"); // typed as numbergetProperty(user, "email"); // Error: "email" is not a key of { name: string; age: number }K extends keyof T is worth pausing on โ keyof T produces a union of Tโs literal property names ("name" | "age" here), and constraining K to extend that union means only real keys of T are ever accepted. T[K] then looks up the exact type of that specific property. This combination โ generics plus keyof plus indexed access โ is how TypeScript expresses โgive me back exactly the right type for whichever key was actually requested,โ something a non-generic signature simply cannot do.
Generic Interfaces and Type Aliases
interface Box<T> { value: T; map<U>(fn: (value: T) => U): Box<U>;}
function createBox<T>(value: T): Box<T> { return { value, map(fn) { return createBox(fn(value)); }, };}
const numberBox = createBox(5);const stringBox = numberBox.map(n => n.toString()); // Box<string>type ApiResult<T> = | { success: true; data: T } | { success: false; error: string };
function unwrap<T>(result: ApiResult<T>): T { if (!result.success) throw new Error(result.error); return result.data;}Generic interfaces and type aliases are how reusable data shapes (a โbox holding some value,โ a โresult that succeeded or failed with some payloadโ) get written once and instantiated for every concrete type your application actually uses, without losing type information at any point.
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; }}
const numberStack = new Stack<number>();numberStack.push(1);numberStack.push(2);numberStack.pop(); // number | undefined
const userStack = new Stack<User>();userStack.push({ name: "Alice", age: 30 });A generic class fixes its type parameter once, at construction (new Stack<number>()), and every method on that instance uses the same concrete type consistently thereafter โ numberStack.push("hello") fails to compile because T was already fixed to number for that specific instance.
Generic Constraints
// Without a constraint, T could be anything โ including something with no .length propertyfunction logLength<T>(item: T) { console.log(item.length); // Error: Property 'length' does not exist on type 'T'}
// With a constraint, T is guaranteed to have .lengthfunction logLength<T extends { length: number }>(item: T) { console.log(item.length); // fine}
logLength("hello"); // strings have .lengthlogLength([1, 2, 3]); // arrays have .lengthlogLength({ length: 5 }); // any object shape with .length workslogLength(42); // Error: number doesn't have .lengthA constraint (extends) narrows what a type parameter is allowed to be, in exchange for letting you actually use properties/methods the constraint guarantees exist. Without a constraint, T could be absolutely anything, so TypeScript refuses to let you assume it has any particular property at all โ this is the same โonly what every member guaranteesโ rule from union narrowing, applied to generics instead.
// A common, practical constraint: requiring an id property for anything "findable"interface HasId { id: string;}
function findById<T extends HasId>(items: T[], id: string): T | undefined { return items.find(item => item.id === id);}
findById([{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }], "1");Default Type Parameters
interface ApiResponse<T = unknown> { data: T; status: number;}
function fetchGeneric<T = unknown>(url: string): Promise<ApiResponse<T>> { return fetch(url).then(res => res.json());}
const response1: ApiResponse = { data: "anything", status: 200 }; // T defaults to unknownconst response2: ApiResponse<User> = { data: someUser, status: 200 }; // T explicitly set to UserA default type parameter (T = unknown) makes the generic usable without specifying T at all, falling back to a sensible default rather than forcing every call site to write out the type argument explicitly โ useful for generic utility types and functions where most callers will specify the type but a reasonable fallback still makes sense for the rest.
Generic Constraints With Multiple Parameters
function merge<T extends object, U extends object>(first: T, second: U): T & U { return { ...first, ...second };}
const merged = merge({ name: "Alice" }, { age: 30 });// merged: { name: string } & { age: number }console.log(merged.name, merged.age);Constraining both T and U to object (rather than leaving them fully open) prevents nonsensical calls like merge("hello", 5), where spreading a string or number wouldnโt make sense โ the constraint documents and enforces the actual requirement the functionโs implementation depends on.
Where Generics Show Up in Code You Already Use
// Array methods are genericconst numbers: number[] = [1, 2, 3];const doubled = numbers.map<number>(n => n * 2); // Array.prototype.map<U>(fn: (value: T) => U): U[]
// Promise is genericconst userPromise: Promise<User> = fetchUser();
// Map and Set are genericconst cache: Map<string, User> = new Map();const seenIds: Set<string> = new Set();
// React's useState is generic (if you've used React)// const [count, setCount] = useState<number>(0);Recognizing that these everyday types are themselves generic is often the moment generics stop feeling abstract โ youโve been using them constantly; writing your own is the same mechanism applied to your own reusable code.
A Realistic End-to-End Example
interface Repository<T extends { id: string }> { findById(id: string): T | undefined; save(item: T): void; delete(id: string): void;}
class InMemoryRepository<T extends { id: string }> implements Repository<T> { private items = new Map<string, T>();
findById(id: string): T | undefined { return this.items.get(id); }
save(item: T): void { this.items.set(item.id, item); }
delete(id: string): void { this.items.delete(id); }}
interface Product { id: string; name: string; price: number;}
const productRepo = new InMemoryRepository<Product>();productRepo.save({ id: "p1", name: "Widget", price: 9.99 });const found = productRepo.findById("p1"); // Product | undefined, fully typedThis is the pattern generics exist for at scale: one Repository<T> interface and one InMemoryRepository<T> implementation, reused for Product, User, Order, or anything else with an id, instead of writing a near-identical repository class per entity type.
A .tsx Gotcha Worth Knowing Early
// In a plain .ts file, this generic arrow function is fine:const firstElement = <T,>(arr: T[]): T => arr[0];// In a .tsx file, <T> alone is ambiguous with JSX syntax and fails to parse:const firstElement = <T>(arr: T[]): T => arr[0]; // Error โ parser reads <T> as a JSX tag
// Fix: add a trailing comma to disambiguate from JSXconst firstElement = <T,>(arr: T[]): T => arr[0];
// Or avoid the ambiguity entirely with a named function declarationfunction firstElement<T>(arr: T[]): T { return arr[0];}This is a genuinely confusing error the first time you hit it in a React + TypeScript project: .tsx files parse <T> as the start of a JSX element by default, since both use angle brackets. The trailing comma (<T,>) is enough to tell the parser โthis is a generic type parameter list, not JSXโ โ many teams simply prefer named function declarations for generic utilities in .tsx files specifically to sidestep the ambiguity altogether.
Frequently Asked Questions
Do I need to always specify the type parameter explicitly, like firstElement<number>([1,2,3])? No โ in almost every case TypeScript infers T from the arguments you pass, which is why most of the examples above never write <T> explicitly at the call site. Explicit type arguments are mainly needed when thereโs nothing for TypeScript to infer from (an empty array literal with no other context, for instance) or when you want to widen/narrow the inferred type deliberately.
Whatโs the difference between T in a generic and any? any disables checking entirely and loses all information about what the value actually is. A generic T preserves a specific, consistent type across an entire function call or class instance โ pass in a number[], and every place T is used inside that call is checked as number, unlike any, where nothing is checked at all.
Why does logLength<T>(item: T) fail without a constraint, when JavaScript would happily let me call .length on anything? JavaScript would happily try โ and then either work or throw undefined is not a function at runtime depending on whether the value actually has that property. TypeScriptโs job is refusing operations it canโt prove are safe at compile time; an unconstrained T could be a number, which has no .length, so the compiler has no basis to allow the call.
Can generic type parameters have default values based on other type parameters? Yes, though itโs a more advanced pattern: interface Pair<T, U = T> lets Pair<string> mean Pair<string, string> while Pair<string, number> still works when both need to differ โ useful for APIs where the two types are related in the common case but genuinely independent in less common ones.
How many type parameters is too many? Thereโs no hard rule, but past two or three, a generic signature usually becomes hard to read and to call correctly. If youโre reaching for four or more type parameters, consider whether some of them belong grouped into a single generic options object type instead.
Why did adding a generic to my React component break in a .tsx file? Almost certainly the JSX-ambiguity issue above โ <T> alone at the start of an arrow functionโs type parameter list is indistinguishable from an opening JSX tag in .tsx. Add the trailing comma or switch to a function declaration.
Whatโs Next
Generics are what make TypeScriptโs type system genuinely expressive rather than just a static type-checker bolted onto JavaScript โ theyโre the foundation for the utility types built into the language itself. Next: Utility Types, where youโll see Partial<T>, Pick<T, K>, Omit<T, K>, and the rest of TypeScriptโs built-in generic helpers, all built from exactly the mechanisms covered here.