TypeScript: Advanced TypeScript Generics
Last updated: 2026-08-26
Now that you’ve mastered the basics of generics, this lesson moves on to more advanced topics—conditional types, infer, and mapped types. These are core tools for type-based programming in TypeScript and form the foundation for understanding the source code of built-in utility types.
1. Conditional Types
Conditional types select different results based on type conditions—similar to the ternary operator at the type level:
(1) Basic Syntax
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes" ("hello" is a string subtype of)
(2) Conditional Types and Union Types (Distributed)
When T is a union type, the conditional type evaluates each member separately in a "distributed" manner:
type ToString<T> = T extends string ? "string" : "other";
// T = string | number → Evaluate each one separately → "string" | "other"
type Result = ToString<string | number>; // "string" | "other"
(3) Disable distributed behavior
Enclose the code in [T] to disable distributed evaluation:
type IsNever<T> = [T] extends [never] ? "yes" : "no";
type A = IsNever<never>; // "yes"
type B = IsNever<string | never>; // "no"(No longer available)
▶ Example: Type-Safe Array Flattening
type Flatten<T> = T extends Array<infer U> ? U : T;
type A = Flatten<string[]>; // string
type B = Flatten<number>; // number
type C = Flatten<boolean[]>; // boolean
Output:
42
2. The infer keyword
infer "Inferring" a type variable in a type condition—it is one of the most powerful tools in TypeScript type programming:
(1) Inferring the Type of Array Elements
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type A = ArrayElement<string[]>; // string
type B = ArrayElement<number[]>; // number
type C = ArrayElement<string>; // never(string Not an array)
(2) Inferring the return type of a function
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type A = GetReturnType<() => string>; // string
type B = GetReturnType<(x: number) => boolean>; // boolean
type C = GetReturnType<(x: string) => void>; // void
type D = GetReturnType<string>; // never(string Not a function)
(3) Inferring Function Parameter Types
type GetParameters<T> = T extends (...args: infer P) => any ? P : never;
type A = GetParameters<(a: string, b: number) => void>; // [string, number]
type B = GetParameters<() => void>; // []
type C = GetParameters<(x: boolean) => string>; // [boolean]
(4) Inferring the Type of a Promise's Resolved Value
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<number[]>>; // number[]
type C = Awaited<string>; // string(No Promise,Return directly)
// nested Promise——Recursive Unpacking
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type D = DeepAwaited<Promise<Promise<string>>>; // string
3. Mapped Types
Mapping Types: Creating a New Type Based on an Old One—Applying Transformations to Each Property:
(1) Basic Syntax
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Optional<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 OptionalUser = Optional<User>;
// { name?: string; age?: number; email?: string }
(2) Mapping Modifiers
Use +/- to add or remove modifiers:
// Remove readonly
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Remove (optional)(?)
type Required2<T> = {
[K in keyof T]-?: T[K];
};
interface Config {
readonly host: string;
readonly port: number;
debug?: boolean;
}
type MutableConfig = Mutable<Config>;
// { host: string; port: number; debug?: boolean } —— readonly Removed
type RequiredConfig = Required2<Config>;
// { readonly host: string; readonly port: number; debug: boolean } —— ? Removed
(3) Key Remapping,TypeScript 4.1+)
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 }
(4) Filter Properties
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
name: string;
age: number;
email: string;
active: boolean;
}
type StringProps = OnlyStrings<Mixed>;
// { name: string; email: string } —— Keep only string Type Properties
▶ Example: Conditional Types with Mapped Types
Output:
42
type Stringify<T> = {
[K in keyof T]: T[K] extends number ? string : T[K];
};
interface Stats {
count: number;
label: string;
active: boolean;
}
type StringifiedStats = Stringify<Stats>;
// { count: string; label: string; active: boolean }
// Only number properties become string
let display: StringifiedStats = {
count: "42",
label: "users",
active: true
};
console.log(display.count); // "42"
Output:
42
▶ Example: Building a Type-Safe API Client
Output:
42
// Definition API Routing Type
type ApiRoutes = {
"/users": { response: { id: number; name: string }[] };
"/users/:id": { response: { id: number; name: string }; params: { id: number } };
"/posts": { response: { id: number; title: string }[] };
};
// Extract Response Type Based on Route
type ApiResponse<R extends keyof ApiRoutes> = ApiRoutes[R]["response"];
type UsersResponse = ApiResponse<"/users">;
// { id: number; name: string }[]
type UserResponse = ApiResponse<"/users/:id">;
// { id: number; name: string }
// Type-safe fetch Function
function fetchApi<R extends keyof ApiRoutes>(
route: R,
...args: "params" extends keyof ApiRoutes[R]
? [params: ApiRoutes[R]["params"]]
: []
): Promise<ApiResponse<R>> {
// Implementation Omitted
return {} as any;
}
// Automatically infer parameters and return types at runtime
// let users = fetchApi("/users"); // No parameters required
// let user = fetchApi("/users/:id", { id: 1 }); // Must be provided params
Output:
Type-safe API client — no runtime output (compile-time type checking only)
4. Default Values for Generics
Generic type parameters can have default values—the default type is used when the type cannot be inferred or does not need to be specified:
interface PaginatedResult<T, PageSize = 10> {
data: T[];
total: number;
pageSize: PageSize;
}
// Use the default values
type UserResult = PaginatedResult<User>;
// data: User[]; total: number; pageSize: 10
// Override the default value
type CustomResult = PaginatedResult<User, 20>;
// data: User[]; total: number; pageSize: 20
(1) Constraints on Default Values
Type parameters with default values must follow parameters without default values:
// ✅ Correct
type A<T, U = string> = { first: T; second: U };
// ❌ Error——Those with default values U When there is no default value, T Previous
// type B<U = string, T> = { first: T; second: U };
5. Covariance and Invariance
This is one of the most confusing advanced concepts in the TypeScript type system—you need to understand it to grasp why some assignments are valid and others are not.
(1) Covariant
"If A is a subtype of B, then Container<A> is also a subtype of Container<B>"—Changes in the same direction:
// string is a subtype of string | number
// string[] Me too (string | number)[] subtypes of → Covariation
let strings: string[] = ["a", "b"];
let mixed: (string | number)[] = strings; // ✅ Covariant Safety
(2) Adjoint (Contravariant)
Function parameter types are inverse—"If A is a subtype of B, then (B => void) is a subtype of (A => void)"—the relationship works in the opposite direction:
// string is a subtype of string | number
// (string | number => void) is a subtype of (string => void) → Inversion
type StringHandler = (arg: string) => void;
type MixedHandler = (arg: string | number) => void;
let mixedHandler: MixedHandler = (arg) => console.log(arg);
let stringHandler: StringHandler = mixedHandler; // ✅ Inversion——Functions that handle broader types can be assigned to variables that handle narrower types.
(3) Why are the function arguments reversed?
// If the parameter is covariant——Unsafe
let dogHandler: (dog: Dog) => void = (dog) => dog.bark();
let animalHandler: (animal: Animal) => void = dogHandler; // ❌ Danger!
// Call animalHandler(cat) → dog.bark() called on cat → Runtime Error
// Inverting is the safe option——Functions that handle broader types can safely handle narrower types.
strictFunctionTypes mode.
❓ FAQ
infer and the generic T?T is a type parameter provided by the caller, while infer is a type variable automatically inferred within a conditional type. T is "you tell me," and infer is "I infer it myself." infer can only be used in the extends clause of a conditional type.[T] extends [U] disables distribution. The IsNever example is the most common scenario where distribution needs to be disabled—never is skipped during distribution when it is a union member.📖 Summary
- The conditional type
T extends U ? X : Yis a ternary operator at the type level - infer: Infer type variables in conditional types—infer array elements, function return values, and Promise resolution values
- A mapping type that creates a new type based on an old type—
{ [K in keyof T]: T[K] }—supports +/- modifiers and key remapping - Generic Default Values
<T = DefaultValue>Provide fallback types for generic parameters - Covariance (same direction) and contravariance (opposite direction) describe subtype relationships for generic types—function return types are covariant, while parameters are contravariant
📝 Exercises
- Basic Problem (Difficulty ⭐): Implement
IsArray<T>using conditional types—if T is an array type, return true; otherwise, return false. TestIsArray<string[]>andIsArray<number>. - Advanced Problem (Difficulty ⭐⭐): Implement
Stringify<T>using a mapping type—convert the types of all an object’s properties tostring. For example,{ age: number }→{ age: string }. - Challenge Problem (Difficulty ⭐⭐⭐): Use
inferand conditional types to implementDeepPromise<T>—recursively unwrap nested Promises until a non-Promise type is obtained. The test caseDeepPromise<Promise<Promise<Promise<number>>>>should return a number.