Generics — The Blueprint of Reusability
Stop writing the same function five times for five different types. Generics let you write one function that works with any type while keeping full IntelliSense and type safety.
📖 The Story: The Copy-Paste Catastrophe
A fintech startup needed a function to find the first item in an array matching a condition. Simple enough, right?
First, they needed it for numbers:
function findFirstNumber(arr: number[], predicate: (item: number) => boolean): number | undefined { for (const item of arr) { if (predicate(item)) return item; } return undefined; }
Then they needed it for strings. Then for Users. Then for Transactions. Each time, they copied the function and changed the types. The codebase had 11 nearly identical functions. When a bug was found in the logic, they had to fix it in 11 places. They missed 3. Production broke.
The CTO rewrote all 11 functions as one generic function in 5 lines. The bug? Fixed once. Forever.
Duplicating code for different types is a design flaw. Generics are the cure.
🎯 Why Generics?
Write Once
One implementation that works with strings, numbers, objects—anything.
Type Safe
Unlike any, generics preserve the exact type. Full autocomplete works.
Fix Once
Bugs are fixed in one place, not in 11 copy-pasted variants.
🧒 Explain Like I'm 10
Imagine a cookie cutter. It's just a shape—a star, a heart, a circle. You don't buy a "star cookie cutter for chocolate" and a "star cookie cutter for vanilla." You buy one star cutter and use it with any dough.
A generic function is that cookie cutter. The "dough" is the type you pass in. The cutter (function) doesn't care what dough it gets—it produces perfectly shaped cookies (typed results) every time.
🎓 Professional Explanation
Generics are type variables. When you write function identity<T>(value: T): T, you're declaring a type parameter T that acts as a placeholder. When the function is called, TypeScript infers (or you explicitly specify) what T is, and every occurrence of T is replaced with that type. This preserves type information throughout the call chain.
📊 How Generics Work
One Function, Many Types
Call with string
identity<string>("hello")
T = string
Call with number
identity<number>(42)
T = number
Call with User
identity<User>(user)
T = User
Same function. Different types. Full safety in every case.
🔴 The Problem (Before Generics)
There are two bad ways to handle "works with any type":
// Option 1: any — LOSES all type safety function firstItem(arr: any[]): any { return arr[0]; } const item = firstItem(["a", "b"]); item.toFixed(2); // No error! Crashes at runtime (strings don't have toFixed) // Option 2: Function overloads — EXPLODES with each new type function firstItemStr(arr: string[]): string { return arr[0]; } function firstItemNum(arr: number[]): number { return arr[0]; } // ... repeat for every type in existence
🔧 The Generic Solution
// T is a type parameter — a placeholder for "whatever type you give me" function firstItem<T>(arr: T[]): T | undefined { return arr[0]; } // TypeScript INFERS that T = string here const str = firstItem(["a", "b", "c"]); str.toUpperCase(); // ✅ TS knows str is a string! str.toFixed(2); // ❌ Error! toFixed doesn't exist on string // TypeScript INFERS that T = number here const num = firstItem([1, 2, 3]); num.toFixed(2); // ✅ TS knows num is a number! // You can also specify T explicitly (usually unnecessary) const explicit = firstItem<string>(["x"]);
The magic: T flows through the entire function. Whatever goes in as T[] comes out as T. No any, no overloads, no duplication. Full IntelliSense.
📦 Generic Interfaces
Interfaces can be generic too. This is the foundation of the Array<T> type you use every day.
// A generic API response wrapper interface ApiResponse<T> { data: T; status: number; message: string; } // Use it with any data shape interface User { id: number; name: string; } const userResponse: ApiResponse<User> = { data: { id: 1, name: "Ram" }, status: 200, message: "Success" }; userResponse.data.name; // ✅ Autocomplete knows this is a string
🔒 Constraints with extends
Sometimes you need T to have certain capabilities. Use extends to constrain it.
// Without constraint: Error! Not every type has .length function getLength<T>(value: T): number { return value.length; // ❌ Property 'length' does not exist on type 'T' } // With constraint: T must have a length property function getLength<T extends { length: number }>(value: T): number { return value.length; // ✅ Now TS knows .length exists! } getLength("hello"); // ✅ 5 (strings have length) getLength([1, 2, 3]); // ✅ 3 (arrays have length) getLength(42); // ❌ Error! Numbers don't have length
🤝 Multiple Type Parameters
// Two type parameters: K for key type, V for value type function merge<K extends string, V>(key: K, value: V): Record<K, V> { return { [key]: value } as Record<K, V>; } const result = merge("id", 123); // result: { id: number }
⚠️ Common Mistakes
Mistake 1: Using any Inside Generics
// Bad: Declares T but never uses it — just uses any function bad<T>(value: any): any { return value; } // Good: Actually uses T function good<T>(value: T): T { return value; }
✅ Best Practices
- Name descriptively: Use
Tfor single params, butTKey/TValuefor clarity in complex APIs. - Let TypeScript infer: Don't explicitly pass
<string>unless inference fails. - Constrain when possible:
T extends { id: string }is safer than unconstrainedT. - Don't over-genericize: If a function only ever works with
User, don't make it generic.
🏗️ Senior Deep Dive
Array<T> Is Just a Generic Interface
The humble string[] you write every day is actually syntactic sugar for Array<string>, which is a generic interface:
interface Array<T> { length: number; push(...items: T[]): number; pop(): T | undefined; map<U>(callbackfn: (value: T, index: number) => U): U[]; // Notice: map takes a DIFFERENT generic (U) for its return! }
Chain of generics: map on Array<T> introduces a new generic U. This is how [1,2,3].map(x => x.toString()) correctly returns string[]—TypeScript infers U = string from your callback.
💼 Interview Questions
Show Answer
any (losing safety) or write separate functions for each type (losing maintainability).T extends { length: number } mean?Show Answer
length property of type number (strings, arrays, etc.). This lets you safely access value.length inside the function.Array.prototype.map uses two generics.Show Answer
map<U>(callback: (value: T) => U): U[] — T comes from the array itself (what's in it), while U is inferred from the callback's return type. This is why [1,2,3].map(String) returns string[].🏋️ Practical Exercise
Build a Generic Function
Write a generic function pluck that takes an array of objects and a property name, returning an array of that property's values.
// Should work like this: const users = [ { name: "Ram", age: 30 }, { name: "Alex", age: 25 } ]; pluck(users, "name"); // ["Ram", "Alex"] — type: string[] pluck(users, "age"); // [30, 25] — type: number[] pluck(users, "email"); // ❌ Error! email doesn't exist on User
Hint: Use two type parameters: <T, K extends keyof T>.
📝 Quiz
Test your understanding. Click an option to check your answer.
<T> represent in function foo<T>(x: T): T?any?any throws away the type. Generics track it, so firstItem(["a"]) returns string, not any.implementsextendsconstraintT extends { length: number } requires T to have a length property.string[] actually shorthand for?List<string>Array<string>Collection<string>string[] = Array<string>.arr.map<U>(cb: (v: T) => U): U[], what determines U?map to change types.📦 Summary
🎯 Key Takeaways
- Generics are type parameters (
<T>) — placeholders filled in at call time. - They eliminate copy-paste duplication while keeping full type safety.
- Use
extendsto constrain T to types with specific properties. - Generic interfaces (
ApiResponse<T>) model reusable data wrappers. Array<T>is a generic — you use generics every day!- Let TypeScript infer types; only specify
<Type>explicitly when inference fails.
Comments
Comments
Post a Comment