TypeScript Advanced Generics — Complete Guide
In this tutorial, you will learn about TypeScript Advanced Generics. We cover key concepts, practical examples, and best practices to help you master this topic.
Advanced TypeScript generics go beyond basic type parameters into generic classes, conditional types that transform types based on conditions, mapped types that iterate over unions, and the infer keyword for extracting types from other types.
What You'll Learn
- Generic classes and factories
- Conditional types with
extendsternary - The
inferkeyword for type extraction - Distributive conditional types
- Mapped types with
in keyof - Real-world advanced generic patterns
Why It Matters
Basic generics let you reuse code across types. Advanced generics let you compute types at compile time — transforming inputs, extracting return types, making properties optional, or building type-safe builders. This is what separates TypeScript from basic type systems.
Real-World Use
React's ComponentProps type extracts the props type from any component. Prisma's generated types use conditional types to handle optional relations. The Doda Browser extension API uses mapped types to convert every method into a version with and without callbacks (Promise-based).
Learning Path
flowchart LR A[Generics Basics] --> B[Advanced Generics] B --> C[Conditional Types] A --> D[You Are Here] C --> E[Mapped Types] E --> F[Utility Types]
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 length(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
console.log(numberStack.peek()); // 10
const stringStack = new Stack<string>();
stringStack.push("a");
stringStack.push("b");
console.log(stringStack.pop()); // b
Generic Factory Functions
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void { this.items.push(item); }
dequeue(): T | undefined { return this.items.shift(); }
}
function createQueue<T>(initialItems?: T[]): Queue<T> {
const queue = new Queue<T>();
if (initialItems) {
for (const item of initialItems) {
queue.enqueue(item);
}
}
return queue;
}
const queue = createQueue([1, 2, 3]);
console.log(queue.dequeue()); // 1
Conditional Types
Conditional types select one type or another based on a condition:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (literal extends string)
Think of conditional types like ternary operators for types: T extends U ? X : Y
Real-World Conditional Types
type ExtractPromise<T> = T extends Promise<infer U> ? U : T;
type A = ExtractPromise<Promise<string>>; // string
type B = ExtractPromise<number>; // number (not a promise)
// Function return type extraction
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => string;
type R = ReturnOf<Fn>; // string
The infer Keyword
infer lets you declare a type variable inside a conditional type's extends clause, capturing a part of the type:
type ArrayItem<T> = T extends Array<infer U> ? U : T;
type A = ArrayItem<string[]>; // string
type B = ArrayItem<number>; // number (not an array)
type FirstArg<T> = T extends (first: infer F, ...args: any[]) => any ? F : never;
type Fn = (name: string, age: number) => void;
type Name = FirstArg<Fn>; // string
Deep Infer
type PromiseValue<T> = T extends Promise<infer U>
? U extends Promise<infer V>
? V
: U
: T;
type A = PromiseValue<Promise<Promise<string>>>; // string
type B = PromiseValue<Promise<number>>; // number
Distributive Conditional Types
When a conditional type acts on a bare generic type parameter, it distributes over unions:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// Distributive: string[] | number[] (not (string | number)[])
// Without distribution — wrap in tuple
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]
Filtering with Distributive Types
type ExcludeNull<T> = T extends null | undefined ? never : T;
type Values = string | number | null | undefined;
type NonNull = ExcludeNull<Values>; // string | number
Mapped Types
Mapped types iterate over the keys of an object type to produce a new type:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};
interface User {
name: string;
age: number;
email: string;
}
type ReadonlyUser = Readonly<User>;
// { readonly name: string; readonly age: number; readonly email: string }
type PartialUser = Partial<User>;
// { name?: string; age?: number; email?: string }
Mapping with Property Modifiers
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type OptionalExceptId<T> = {
[K in keyof T as K extends "id" ? K : never]: T[K];
} & {
[K in keyof T as K extends "id" ? never : K]?: T[K];
};
Template Literal Types with Generics
type EventName<T extends string> = `on${Capitalize<T>}`;
type Events = EventName<"click" | "submit">; // "onClick" | "onSubmit"
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person { name: string; age: number; }
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
Common Mistakes
1. Forgetting That Conditional Types Distribute Over Unions
type IsString<T> = T extends string ? true : false;
type Result = IsString<string | number>;
// Distributes: IsString<string> | IsString<number> = true | false = boolean
Wrap in [T] extends [U] to prevent distribution.
2. Overusing infer When a Simpler Type Exists
// Overkill
type ElementType<T> = T extends (infer U)[] ? U : never;
// Already exists as a utility
// T[number] — indexed access
type ElementType<T> = T extends unknown[] ? T[number] : never;
3. Creating Unreadable Nested Conditionals
// Hard to read
type WhatIsThis<T> = T extends string
? T extends `#${string}`
? "hex-color"
: "regular-string"
: T extends number
? "number"
: "other";
// Better with helper types
type HexString<T> = T extends `#${string}` ? true : false;
type WhatIsThis<T> =
T extends string ? (HexString<T> extends true ? "hex-color" : "regular-string")
: T extends number ? "number"
: "other";
4. Recursive Conditionals Without a Base Case
type FlattenDeep<T> = T extends unknown[]
? FlattenDeep<T[number]> // No base case — infinite recursion
: T;
// Fix: add base case for non-array
type FlattenDeep<T> = T extends (infer U)[]
? FlattenDeep<U>
: T;
5. Not Using as in Mapped Types for Key Remapping
TS 4.1+ lets you remap keys with as:
// Without remapping — just same keys
type Without<T> = { [K in keyof T]: T[K] };
// With remapping — transform keys
type WithPrefix<T> = { [K in keyof T as `_${string & K}`]: T[K] };
Practice Questions
What is a conditional type and how is it written? A type that selects between two types based on a condition:
T extends U ? X : Y.What does the
inferkeyword do? It declares a type variable inside a conditional's extends clause to capture and use a part of the matched type.What is distributive conditional typing? When a conditional type on a bare generic distributes over union members, applying the condition to each union member individually.
How do you iterate over object keys in a mapped type? Using
[K in keyof T]: T[K], wherekeyof Tproduces a union of property keys.
Challenge: Write a DeepReadonly<T> mapped type that makes all properties of an object and its nested objects readonly. Use it on a nested configuration object.
FAQ
Mini Project: Type-Safe Event Emitter
// src/event-emitter.ts
type EventMap = Record<string, unknown[]>;
class TypedEventEmitter<T extends EventMap> {
private listeners: {
[K in keyof T]?: Array<(...args: T[K]) => void>;
} = {};
on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): void {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, ...args: T[K]): void {
const handlers = this.listeners[event];
if (handlers) {
for (const handler of handlers) {
handler(...args);
}
}
}
off<K extends keyof T>(event: K, listener: (...args: T[K]) => void): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter(h => h !== listener) as any;
}
}
}
interface AppEvents {
scanComplete: [scanId: string, threats: number];
threatDetected: [threatName: string, severity: number];
error: [message: string];
}
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("scanComplete", (scanId, threats) => {
console.log(`Scan ${scanId}: ${threats} threats found`);
});
emitter.on("threatDetected", (name, severity) => {
console.log(`Threat: ${name} (severity: ${severity})`);
});
emitter.emit("scanComplete", "scan-001", 3);
emitter.emit("threatDetected", "Trojan.Generic", 7);
// emitter.emit("scanComplete", 123); // Error — wrong argument types
Expected output:
Scan scan-001: 3 threats found
Threat: Trojan.Generic (severity: 7)
What's Next
Now explore keyof, typeof, and indexed access types:
| Lesson | Description |
|---|---|
| {{< ref "/programming-languages/typescript/09-generics-basics" >}} | Review generics basics |
| {{< ref "/programming-languages/typescript/11-keyof-typeof" >}} | Keyof, typeof, indexed access types |
| {{< ref "/programming-languages/typescript/12-template-literal-types" >}} | Template literal types |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro