Why Generics Are Non-Negotiable
Without generics, you face a choice: use "any" (lose type safety) or write duplicate functions for each type (lose maintainability). Generics let you write functions, classes, and types that work with ANY type while preserving full type information.
Every library you use — React, Prisma, tRPC, Zod, Express — is built on generics. useState<T>, Array<T>, Promise<T>, Map<K,V> — these are all generic types. Understanding generics is the prerequisite for reading library source code and writing reusable code.
TypeScript 5.x has significantly enhanced generics with const type parameters (5.0), higher-order type inference improvements, and better narrowing inside generic functions. These features make the type system more expressive than ever.
Key Takeaways
Generic Functions: The Foundation
A generic function declares one or more type parameters using angle brackets. When called, TypeScript infers the type from the arguments, or you can specify it explicitly. The type parameter is then used to enforce relationships between inputs and outputs.
The key insight is that T is not just documentation — it creates a type-level contract. If a function returns T, TypeScript guarantees the output type matches the input type exactly, not some widened version.
// Basic generic function — T links input type to output type function firstElement<T>(arr: T[]): T | undefined { return arr[0]; } const num = firstElement([1, 2, 3]); // type: number const str = firstElement(['a', 'b', 'c']); // type: string const obj = firstElement([{ id: 1 }]); // type: { id: number } // Multiple type parameters — linking two types function zip<A, B>(a: A[], b: B[]): [A, B][] { return a.map((val, i) => [val, b[i]!]); } const pairs = zip([1, 2], ['a', 'b']); // type: [number, string][] // value: [[1, 'a'], [2, 'b']] // Generic arrow function (note the trailing comma in JSX files) const identity = <T,>(value: T): T => value; // The trailing comma <T,> prevents JSX from thinking it's a tag
Generic Constraints with extends
Unconstrained generics accept everything, but sometimes you need to guarantee that T has specific properties. The extends keyword constrains a type parameter to types that satisfy a specific shape.
Constraints do not limit T to only that type — they set a minimum requirement. T extends HasLength means T must have at least a length property, but it can have any other properties too. This is structural typing in action.
// Constraint: T must have a .length property interface HasLength { length: number; } function logWithLength<T extends HasLength>(item: T): T { console.log(`Length: ${item.length}`); return item; } logWithLength("hello"); // ✅ string has .length logWithLength([1, 2, 3]); // ✅ array has .length logWithLength({ length: 10 }); // ✅ object literal matches logWithLength(42); // ❌ Error: number has no .length // keyof constraint — T must be a key of the object function getProperty<Obj, Key extends keyof Obj>(obj: Obj, key: Key): Obj[Key] { return obj[key]; } const user = { name: 'Ashutosh', age: 28, role: 'Lead Developer' }; const name = getProperty(user, 'name'); // type: string const age = getProperty(user, 'age'); // type: number getProperty(user, 'email'); // ❌ Error: "email" is not a key of user
Key Takeaways
Conditional Types: Type-Level If/Else
Conditional types use the ternary syntax T extends U ? X : Y to create types that change based on a condition. They are the if/else of the type system and enable extremely powerful type transformations.
When used with generics, conditional types distribute over unions. This means if T is a union type A | B, the conditional type checks each member separately: (A extends U ? X : Y) | (B extends U ? X : Y). This distributive behavior is key to understanding utility types like Exclude and Extract.
// Basic conditional type type IsString<T> = T extends string ? 'yes' : 'no'; type A = IsString<string>; // 'yes' type B = IsString<number>; // 'no' type C = IsString<'hello'>; // 'yes' (string literal extends string) // Distributive conditional types (distributes over unions) type ToArray<T> = T extends any ? T[] : never; type D = ToArray<string | number>; // Distributes: string[] | number[] (NOT (string | number)[]) // Non-distributive: wrap in tuple to prevent distribution type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type E = ToArrayNonDist<string | number>; // Does NOT distribute: (string | number)[] // Practical: Extract only functions from an object type type FunctionKeys<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T]; interface API { baseUrl: string; timeout: number; getUser: (id: string) => Promise<User>; deleteUser: (id: string) => Promise<void>; } type APIFunctions = FunctionKeys<API>; // "getUser" | "deleteUser"
The infer Keyword: Pattern Matching for Types
The infer keyword lets you "extract" types from inside other types within conditional type expressions. Think of it as destructuring at the type level — it pattern-matches against a type structure and captures inner types.
infer is what makes utility types like ReturnType<T>, Parameters<T>, and Awaited<T> possible. It is arguably the most powerful tool in TypeScript's type system and is essential for advanced library authoring.
// Extract return type of a function type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type Fn = (x: number, y: string) => boolean; type Result = MyReturnType<Fn>; // boolean // Extract the type inside a Promise type UnwrapPromise<T> = T extends Promise<infer Inner> ? Inner : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<Promise<number>>>; // Promise<number> type C = UnwrapPromise<string>; // string (passthrough) // Deep unwrap (recursive) type DeepUnwrap<T> = T extends Promise<infer Inner> ? DeepUnwrap<Inner> : T; type D = DeepUnwrap<Promise<Promise<Promise<boolean>>>>; // boolean // Extract array element type type ElementOf<T> = T extends (infer E)[] ? E : never; type Users = ElementOf<User[]>; // User // Extract first argument type type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never; type LoginArg = FirstArg<(email: string, password: string) => void>; // string
Key Takeaways
Mapped Types: Transform Every Property
Mapped types iterate over the keys of a type and transform each property. They use the [Key in keyof T] syntax — essentially a "for loop" over type properties. Combined with modifiers (+/- readonly, +/- optional), they can make properties required, optional, readonly, or mutable.
TypeScript's built-in utility types — Partial<T>, Required<T>, Readonly<T>, Pick<T,K>, and Record<K,V> — are all mapped types. Understanding how they work lets you create your own domain-specific utility types.
// How Partial<T> works internally type MyPartial<T> = { [K in keyof T]?: T[K]; // +? makes each property optional }; // How Required<T> works internally type MyRequired<T> = { [K in keyof T]-?: T[K]; // -? removes optional modifier }; // Custom: Make all properties nullable type Nullable<T> = { [K in keyof T]: T[K] | null; }; interface UserConfig { theme: string; language: string; notifications: boolean; } type NullableConfig = Nullable<UserConfig>; // { theme: string | null; language: string | null; notifications: boolean | null } // Key remapping with `as` (TS 4.1+) type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]; }; type UserGetters = Getters<{ name: string; age: number }>; // { getName: () => string; getAge: () => number } // Filter properties by type type OnlyStrings<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K]; }; type StringProps = OnlyStrings<{ name: string; age: number; email: string }>; // { name: string; email: string }
Template Literal Types: String Manipulation at the Type Level
Template literal types let you construct and pattern-match string types using backtick syntax — the same template literals you use in runtime code, but at the type level. Combined with mapped types and conditional types, they enable precise string type manipulation.
This feature powers libraries like Prisma (model.$findMany), tRPC (procedure definition), and type-safe routing. It is one of TypeScript's most unique features — no other mainstream type system has string-level type computation.
// Basic template literal type type EventName = `on${"Click" | "Hover" | "Focus"}`; // "onClick" | "onHover" | "onFocus" // Cartesian product of unions type Color = 'red' | 'blue'; type Size = 'sm' | 'lg'; type Variant = `${Color}-${Size}`; // "red-sm" | "red-lg" | "blue-sm" | "blue-lg" // Type-safe event system type EventMap = { userCreated: { userId: string; email: string }; postPublished: { postId: string; title: string }; orderPlaced: { orderId: string; amount: number }; }; type EventHandler<T extends keyof EventMap> = (payload: EventMap[T]) => void; function on<T extends keyof EventMap>(event: T, handler: EventHandler<T>) { // Implementation... } on('userCreated', (payload) => { console.log(payload.userId); // ✅ Type-safe: { userId: string; email: string } console.log(payload.amount); // ❌ Error: 'amount' doesn't exist }); on('invalidEvent', () => {}); // ❌ Error: not in EventMap
Key Takeaways
const Type Parameters (TypeScript 5.0+)
The const modifier on type parameters is a TypeScript 5.0+ feature that tells the compiler to infer the narrowest possible type — literal types, readonly tuples, and readonly object types. Without const, TypeScript widens literals to their base types.
This is critical for configuration objects, route definitions, and any API where you want to preserve exact literal values at the type level. Before TS 5.0, you had to use "as const" at every call site, which was easy to forget.
// Without const: types are widened function createRoutes<T extends Record<string, string>>(routes: T): T { return routes; } const routes1 = createRoutes({ home: '/', blog: '/blog' }); // type: { home: string; blog: string } — literals are lost! // With const: literal types are preserved function createRoutes<const T extends Record<string, string>>(routes: T): T { return routes; } const routes2 = createRoutes({ home: '/', blog: '/blog' }); // type: { readonly home: "/"; readonly blog: "/blog" } — exact literals! // Real-world: Type-safe config builder function defineConfig<const T extends { port: number; env: 'development' | 'staging' | 'production'; features: string[]; }>(config: T): T { return config; } const config = defineConfig({ port: 3000, env: 'production', features: ['auth', 'analytics', 'blog'], }); // config.env is exactly 'production', not the union type // config.features is readonly ['auth', 'analytics', 'blog']
Real-World Pattern: Type-Safe API Client
This pattern combines generics, mapped types, conditional types, and template literals to create a fully type-safe API client. The types guarantee that request bodies, URL parameters, and responses all match your API schema at compile time.
This is the same pattern used by libraries like tRPC, Hono, and Elysia. Understanding it makes you capable of building (or contributing to) professional TypeScript libraries.
// Define your API schema interface APISchema { 'GET /users': { response: User[]; query: { page: number; limit: number } }; 'GET /users/:id': { response: User; params: { id: string } }; 'POST /users': { response: User; body: CreateUserInput }; 'PUT /users/:id': { response: User; params: { id: string }; body: UpdateUserInput }; 'DELETE /users/:id': { response: void; params: { id: string } }; } // Extract method and path type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; type Routes = keyof APISchema; // Type-safe client type RequestConfig<T extends Routes> = Omit<APISchema[T], 'response'>; type ResponseOf<T extends Routes> = APISchema[T]['response']; async function apiClient<T extends Routes>( route: T, config: RequestConfig<T> ): Promise<ResponseOf<T>> { // Implementation: parse route, substitute params, make fetch call const [method, path] = (route as string).split(' '); const response = await fetch(path, { method }); return response.json(); } // Usage — fully type-safe! const users = await apiClient('GET /users', { query: { page: 1, limit: 10 } }); // users: User[] const user = await apiClient('GET /users/:id', { params: { id: '123' } }); // user: User await apiClient('POST /users', { body: { name: 'Ashutosh', email: 'a@ih.com' } }); // body is typed as CreateUserInput — compile error if wrong shape
Key Takeaways
Top Interview Questions on TypeScript Generics
Q1: What problem do generics solve? → They enable reusable, type-safe code without "any" or duplication.
Q2: What is the difference between <T> and <T extends X>? → Unconstrained T accepts everything. T extends X requires T to satisfy shape X.
Q3: Explain conditional types → T extends U ? X : Y — type-level if/else. Distributes over unions by default.
Q4: What does infer do? → Captures a type inside a conditional type. Used to build ReturnType, Parameters, and Awaited.
Q5: How do mapped types work? → [K in keyof T] iterates over all keys of T, letting you transform each property.
Q6: What are template literal types? → Backtick types that compute new string types. Enable type-safe event names, routes, etc.
Q7: What is keyof? → Extracts all property names of a type as a union. keyof { a: 1; b: 2 } = "a" | "b".
Q8: Explain the const modifier in type parameters → Forces narrowest inference (literal types). Eliminates need for "as const" at call sites.
Q9: How does Partial<T> work internally? → Mapped type: { [K in keyof T]?: T[K] } — adds optional modifier to all properties.
Q10: What is a higher-kinded type, and does TS support it? → A type that takes another type as a parameter (like Functor). TS does not support true HKTs, but you can simulate them with generic interfaces.
Key Takeaways
TypeScript generics are not just a feature — they are the foundation of the type system. Every type-safe library, every React hook type, every API client is built on generics. Mastering them is the difference between a TypeScript user and a TypeScript expert.
The progression is: generic functions → constraints → conditional types → infer → mapped types → template literals → const type parameters. Each layer builds on the previous one, and together they enable compile-time guarantees that catch entire categories of bugs.
In 2026, with TypeScript 5.x, the type system is mature enough to express almost any type relationship. The question is no longer "can TypeScript type this?" but "how do I type this correctlyness?"