Generics let you parameterise types the way functions parameterise values — you get reusability without losing type safety
Type argument inference works from argument to parameter; it cannot infer from the return type
Constraints with extends preserve the exact type, not widen to the constraint shape
Conditional types distribute over unions by default; wrap in [T] to switch off distribution
Deeply recursive generics hit a ~100-level recursion limit; use an accumulator pattern to stay shallow
✦ Definition~90s read
What is TypeScript Generics?
TypeScript generics are a compile-time mechanism for preserving type relationships across functions, classes, and types — not merely a tool for code reuse. When you write a merge function like function merge<T, U>(a: T, b: U): T & U, generics prevent the silent any that would occur if you used any or lost type information.
★
Imagine a vending machine that only sells one specific snack — that's a regular typed function.
Without generics, merging two objects would return a type with no specific shape, forcing you to cast or lose autocomplete. Generics solve this by capturing the exact types of the arguments at call sites and threading them through the return type, ensuring the compiler knows the precise structure of the result.
This is why they're essential in libraries like Lodash's merge or Redux's combineReducers — they let you compose types without erasing them.
In practice, generics are inferred from usage, not explicitly annotated in most cases. TypeScript uses a unification-based inference algorithm: when you call merge({ name: 'Alice' }, { age: 30 }), it infers T as { name: string } and U as { age: number }.
Constraints like T extends object bound type parameters to prevent invalid inputs, while keyof lets you write function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] — a pattern used in ORMs like Prisma for type-safe field access. Conditional types (`T extends U ?
X : Y) and mapped types ({ [K in keyof T]: T[K] | null }) build on generics to create utility types like Partial<T> or Pick<T, K>`, which are the backbone of TypeScript's type system.
Production patterns go further: generic classes like class Stack<T> { push(item: T): void; pop(): T | undefined } enforce type safety across instances, while higher-kinded type workarounds (since TypeScript lacks true higher-kinded types) use techniques like interface HKT<F, A> to simulate functors — seen in libraries like fp-ts. Branded types (type UserId = string & { __brand: 'UserId' }) and phantom types (type parameters unused in runtime but enforced at compile time) prevent mixing up IDs or currencies, a pattern used in production at companies like Airbnb and Uber to avoid costly bugs.
Performance-wise, generics have zero runtime cost — they're fully erased during compilation — but complex conditional types can slow down the compiler; avoid deeply nested generics in hot paths of your type definitions.
Plain-English First
Imagine a vending machine that only sells one specific snack — that's a regular typed function. Now imagine a vending machine that can hold ANY snack, but still guarantees whatever you put in is exactly what comes out — no mystery items. That's a generic. It doesn't care what type flows through it, but it remembers and enforces that type across the entire operation. The shape of the data is locked in the moment you use it, not when you write the code.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Most TypeScript developers can write a generic function. Far fewer understand what the compiler is actually doing when it sees angle brackets — and that gap is exactly where subtle bugs and unmaintainable code live. Generics aren't just syntactic sugar for 'accept anything'. They're TypeScript's mechanism for parameterising types the same way functions parameterise values. Get them right and you write code that's simultaneously maximally flexible and maximally safe. Get them wrong and you end up with type assertions everywhere, silently broken inference, and any creeping back in through the side door.
The core problem generics solve is the tension between reusability and type safety. Before generics, you had two bad options: write a separate function for every type (rigid, repetitive) or accept any (flexible, but you've thrown away the compiler). Generics let you write one function that the compiler specialises for each call site, preserving full type information throughout. The compiler is doing work that previously only happened at runtime — and it's doing it for free.
By the end of this article you'll be able to write generic utilities that infer correctly without explicit type arguments, apply constraints that catch real bugs at compile time, compose conditional and mapped types to build the kinds of utility types you see in TypeScript's own standard library, and avoid the five production gotchas that silently corrupt type safety in large codebases.
Generics Are Not About Reusability — They're About Preserving Type Relationships
Generics let you write a function, class, or type that works with any type while keeping that type locked in. The core mechanic is a type variable — a placeholder like T that captures the caller's type at the call site. Without generics, you'd either lose the type (return any) or duplicate code for every type. A generic function like function identity<T>(arg: T): T returns exactly the type you pass in, not a widened version. That's the entire point: the type is preserved, not erased.
In practice, generics are resolved at compile time. TypeScript infers T from the argument, then checks every usage of T inside the function body for consistency. The key property: constraints (extends) let you restrict T to a shape while still keeping the exact subtype. function merge<T extends object, U extends object>(a: T, b: U): T & U ensures both arguments are objects, but the return type is the precise intersection of the two input types — not a generic object.
Use generics when you need to maintain a type relationship across boundaries: a function that returns the same type it receives, a data structure that holds a consistent element type, or a utility that transforms one type into another. The cost is readability — excessive generics can obscure intent. The rule: if you can't name the type variable meaningfully, you probably don't need it. Generics exist to prevent the silent any that creeps into merge functions, identity wrappers, and collection utilities.
⚠ Generics Are Not `any`
any disables type checking entirely. A generic preserves the type — it's the difference between returning any and returning the exact type the caller passed in.
📊 Production Insight
A team wrote a generic merge(a, b) that returned T & U but forgot to constrain T and U to objects — passing primitives produced never at runtime, breaking a critical data pipeline. The symptom: TypeScript compiled fine, but the merged result was always undefined. The rule: always constrain generic type parameters to the minimum shape required by the function body.
🎯 Key Takeaway
Generics preserve the exact type from input to output — they are not a way to bypass the type system.
Constraints (extends) are not optional; they prevent impossible type combinations that compile but fail at runtime.
If a generic function's type variable appears only once, you probably don't need a generic — use the concrete type or unknown.
thecodeforge.io
Typescript Generics
How TypeScript Actually Infers Generic Type Parameters
When you call a generic function without providing explicit type arguments, TypeScript runs a unification algorithm to figure out what each type parameter should be. It looks at the types of the arguments you passed, maps them against the function's parameter types, and solves for the type variables. This is called type argument inference and it's more powerful — and more fragile — than most developers realise.
Inference flows in one direction: from argument to parameter. TypeScript can infer T from the value you pass in, but it cannot infer T from the return type you're expecting. That asymmetry is important. If inference fails or is ambiguous, TypeScript widens to a union or falls back to unknown, and you'll see unexpected broadening in downstream types.
Contextual typing is inference's quieter sibling. When a generic function is passed as a callback, TypeScript can infer its type parameters from the expected signature at the call site — but only when the callback is inline. Assign it to a variable first and that context is lost. This trips up even experienced developers when they refactor inline lambdas into named functions.
Understanding inference order also matters for conditional types. Deferred inference — where TypeScript can't resolve a conditional type at declaration time because the type parameter is still free — produces T extends U ? A : B literally in your hover tooltips, which can make debugging type errors genuinely confusing. Knowing when inference is eager versus deferred is the difference between readable and opaque type errors.
genericInference.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// TypeScript infers T from the argument — no explicit <string> neededfunction wrapInArray<T>(value: T): T[] {
return [value]; // T is locked in as soon as we call the function
}
const stringArray = wrapInArray('hello'); // inferred: string[]
const numberArray = wrapInArray(42); // inferred: number[]
const dateArray = wrapInArray(new Date()); // inferred: Date[]// Inference FAILS when it's ambiguous — TypeScript widens to a unionfunction pickFirst<T>(a: T, b: T): T {
return a;
}
// Both args must agree on T — TypeScript picks the wider typeconst result = pickFirst('hello', 42);
// ^ Error: Argument of type 'number' is not assignable to parameter of type 'string'// Fix: be explicit when you WANT a unionfunction pickFirstUnion<T, U>(a: T, b: U): T | U {
return a;
}
const safeResult = pickFirstUnion('hello', 42); // inferred: string | number ✓// Contextual typing: inference works inline but NOT after assignmentconst numbers = [1, 2, 3];
// ✓ TypeScript infers T = number from the array's element typeconst doubled = numbers.map(n => n * 2);
// ✗ Context is lost — TypeScript can't infer T from a pre-assigned callback
const triple = (n: number) => n * 3; // still fine because we typed it
const tripled = numbers.map(triple); // works, but only because we annotated// The tricky case: a generic callback loses inference when pre-assignedfunction transform<T>(items: T[], fn: (item: T) => T): T[] {
return items.map(fn);
}
// ✓ Inline: TypeScript infers T = numberconst r1 = transform([1, 2, 3], n => n * 2);
// ✓ Pre-assigned BUT explicitly typed: still worksconst double = (n: number): number => n * 2;
const r2 = transform([1, 2, 3], double); // T = number, inferred from array// ✗ Generic pre-assigned callback: inference breaks — T stays unresolved// const genericDouble = <T>(n: T) => n; // T can't be inferred from call site// const r3 = transform([1,2,3], genericDouble); // Error: T is not assignable to T
console.log(stringArray); // [ 'hello' ]
console.log(numberArray); // [ 42 ]
console.log(doubled); // [ 2, 4, 6 ]
console.log(r1); // [ 2, 4, 6 ]
When TypeScript can't pick a single type for T, it doesn't error immediately — it widens to a union or unknown. You won't see a red squiggle at the call site, but the return type becomes so broad it's useless. Always check hover types when inference feels surprising. If you see string | number where you expected string, you have an ambiguous call.
📊 Production Insight
The widest source of inference bugs in large codebases is contextual typing loss. When you extract an inline lambda into a named function, you lose the call-site context that let the compiler determine T. Add explicit type annotations to named functions that are used as generic callbacks.
Deferred inference in conditional types produces hover text that looks like T extends string ? A : B — that's not a bug, it means T is still unresolved. The fix is to ensure T is concrete at the point of use, usually by providing an explicit type argument.
🎯 Key Takeaway
Inference works from arguments, not return types.
If inference fails, TypeScript doesn't error — it widens silently.
Always hover the return type at the call site before merging.
Diagnosing Inference Failures
IfReturn type is unknown or a union you didn't expect
→
UseCheck if the type parameter appears in at least one argument position — inference only flows from arguments.
IfHover shows T extends ... literally
→
UseT is still free. Provide an explicit <Type> at the call site or ensure the context constrains it.
IfCallback loses inference after refactoring to a named function
→
UseAdd explicit type parameters to the named function — the contextual typing is lost when you extract it.
Constraints, keyof, and the Power of Bounded Type Parameters
An unconstrained generic (T) is useful for identity-style functions, but most real-world generics need to know something about their type parameter. That's what extends does in the constraint position — it doesn't mean inheritance, it means 'T must be assignable to this shape'. The constraint is a lower bound on what T can be.
The combination of keyof and a constrained generic unlocks one of TypeScript's most powerful patterns: type-safe property access. K extends keyof T tells the compiler that K is literally one of the property names of T, so obj[key] is always valid and the return type is exactly T[K] — not any, not unknown, but the precise type of that property.
Constraints also participate in inference. When TypeScript sees fn<T extends Record<string, unknown>>(obj: T), it infers T as the exact shape of whatever you pass in, not just Record<string, unknown>. The constraint is a floor, not a ceiling — which means you preserve the specific type while still enforcing the minimum shape requirement.
Conditional constraints via infer take this further. You can extract type components from complex types at compile time — pulling the return type out of a function type, the element type from an array type, or the resolved value from a Promise — all without running a single line of code. The TypeScript compiler is doing static symbolic execution here.
constrainedGenerics.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// ─────────────────────────────────────────────// 1. Basic constraint: T must have a .length property// ─────────────────────────────────────────────interfaceHasLength {
length: number;
}
function logLengthAndReturn<T extendsHasLength>(item: T): T {
console.log(`Length: ${item.length}`);
return item; // we return T, not HasLength — the specific type is preserved
}
const sentence = logLengthAndReturn('TypeScript generics'); // T = string
const integers = logLengthAndReturn([1, 2, 3, 4, 5]); // T = number[]// sentence is still typed as string, not HasLength — T flows through intact// ─────────────────────────────────────────────// 2. keyof constraint: type-safe dynamic property access// ─────────────────────────────────────────────function getProperty<TObject, TKeyextends keyof TObject>(
obj: TObject,
key: TKey
): TObject[TKey] { // return type is the EXACT type of that propertyreturn obj[key];
}
const userProfile = {
id: 101,
username: 'alice_dev',
joinedAt: newDate('2021-03-15'),
isVerified: true,
};
const userId = getProperty(userProfile, 'id'); // type: number
const username = getProperty(userProfile, 'username'); // type: string
const joinDate = getProperty(userProfile, 'joinedAt'); // type: Date// getProperty(userProfile, 'email'); // ✗ Error: 'email' is not a key of userProfile// ─────────────────────────────────────────────// 3. infer: extracting type components at compile time// ─────────────────────────────────────────────// Extract the resolved type from a PromisetypeAwaited2<T> = T extendsPromise<infer Resolved> ? Resolved : T;
type StringResult = Awaited2<Promise<string>>; // string
type NumberResult = Awaited2<Promise<number>>; // number
type AlreadyPlain = Awaited2<boolean>; // boolean (not a Promise, returns T)// Extract parameter types from a function signaturetypeFirstParam<TFnextends (...args: any[]) => any> =
TFnextends (first: infer P, ...rest: any[]) => any ? P : never;
typeFetchFn = (url: string, retries: number) => Promise<Response>;
type FetchFirstArg = FirstParam<FetchFn>; // string ✓// Extract element type from an arraytypeArrayElement<TArrextendsreadonly unknown[]> =
TArrextendsreadonly (infer Element)[] ? Element : never;
const statusCodes = [200, 201, 400, 404, 500] asconst;
type StatusCode = ArrayElement<typeof statusCodes>; // 200 | 201 | 400 | 404 | 500// ─────────────────────────────────────────────// 4. Constraint preservation: T stays specific, not widened// ─────────────────────────────────────────────function mergeWithDefaults<T extendsRecord<string, unknown>>(
defaults: T,
overrides: Partial<T> // Partial works because T is already constrained
): T {
return { ...defaults, ...overrides };
}
const defaultConfig = { timeout: 3000, retries: 3, verbose: false };
const userConfig = mergeWithDefaults(defaultConfig, { timeout: 5000 });
// userConfig is typed as { timeout: number; retries: number; verbose: boolean }// — NOT as Record<string, unknown>. The specific shape is preserved.
console.log(userId); // 101
console.log(username); // alice_dev
console.log(userConfig); // { timeout: 5000, retries: 3, verbose: false }
There's a critical difference between fn<T extends Shape>(x: T): T and fn(x: Shape): Shape. The first preserves T's exact type in the return — the second erases it to Shape. Use the generic form whenever callers need the specific type back, and the simpler form when they don't. Erasing unnecessarily is one of the most common sources of type unsafety in shared utilities.
📊 Production Insight
A common production mistake: using fn<T extends Entity>(x: T): Entity instead of fn<T extends Entity>(x: T): T. The first erases the specific type, so callers get Entity instead of Dog or User. That breaks downstream chaining and forces type assertions.
Another trap: forgetting that keyof includes symbol and number keys. If your object has a numeric index signature, keyof T may include number, and your getter function can accept numeric keys — which may or may not be intended.
🎯 Key Takeaway
A constraint is a floor, not a ceiling — the compiler preserves the caller's exact type.
If you don't need the type preserved, don't use a generic.
infer lets you extract type components at compile time — use it to build your own utility types.
When to Use a Constraint vs. Simple Type
IfYou only need one property of the object (e.g., .length)
→
UseUse a constrained generic: <T extends { length: number }> preserves the exact type.
IfYou don't need to return the specific type
→
UseUse the plain interface as the parameter type: fn(x: Shape): string is cleaner.
IfYou need to return a modified version of the input type
→
UseUse a constrained generic combined with a mapped type — e.g., Partial<T> or a custom transform.
thecodeforge.io
Typescript Generics
Conditional and Mapped Types — Building Utility Types From Scratch
Conditional types (`T extends U ? A : B) are the if/else of the type system. They let you compute output types based on input type structure, which is how TypeScript's own Partial, Required, Readonly, ReturnType, and Extract` are all built. Understanding them moves you from a consumer of the standard library to someone who can extend it.
Distributivity is the most misunderstood behaviour in conditional types. When T is a naked type parameter (not wrapped in a tuple or array), a conditional type distributes over unions automatically. T extends string ? 'yes' : 'no' applied to string | number produces 'yes' | 'no' — not a single boolean. This is almost always what you want, but when it's not, wrapping in a tuple ([T] extends [string]) turns off distribution.
Mapped types iterate over a union of keys and transform each one. Combined with conditional types, you can filter keys by their value type, make properties selectively optional, remap key names, and even change the modifier (readonly/optional) per-property. The as clause in mapped types (TypeScript 4.1+) lets you remap keys through a template literal or conditional type, enabling patterns like converting all keys to camelCase at the type level.
The real power emerges when you compose them. A mapped type that uses a conditional type in its value position, applied via a generic parameter, gives you arbitrary type transformations that are both provably correct and instantly readable in IDE hover documentation.
utilityTypesFromScratch.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// ─────────────────────────────────────────────// 1. Rebuilding Partial and Required from scratch// ─────────────────────────────────────────────// -? removes the optional modifier; +? adds it; ? is shorthand for +?typeDeepPartial<T> = {
[K in keyof T]+?: T[K] extendsobject ? DeepPartial<T[K]> : T[K];
// ^^^ +? makes every key optional, recursing into nested objects
};
typeStrictRequired<T> = {
[K in keyof T]-?: T[K]; // -? strips the optional modifier from every key
};
interfaceAppConfig {
server: {
host: string;
port: number;
ssl: boolean;
};
database: {
url: string;
poolSize?: number;
};
featureFlags?: {
darkMode: boolean;
betaAccess: boolean;
};
}
// Deep partial: every nested field is optional — useful for config patchesconst configPatch: DeepPartial<AppConfig> = {
server: { port: 8080 } // host and ssl are now optional — valid ✓
};
// ─────────────────────────────────────────────// 2. Distributive conditional types — and how to turn them off// ─────────────────────────────────────────────// Distributive: T is a naked type parameter, so this distributes over unionstypeIsString<T> = T extendsstring ? true : false;
type TestA = IsString<string>; // true
type TestB = IsString<number>; // false
type TestC = IsString<string | number>; // true | false — distributes! ✓ (usually what you want)// Non-distributive: wrapping in a tuple prevents distributiontypeIsExactlyString<T> = [T] extends [string] ? true : false;
type TestD = IsExactlyString<string | number>; // false — the whole union is checked, not each member// ─────────────────────────────────────────────// 3. Filtering keys by value type — a practical mapped + conditional combo// ─────────────────────────────────────────────// Extract only the keys of T whose values are assignable to ValueTypetypeKeysOfType<T, ValueType> = {
[K in keyof T]: T[K] extendsValueType ? K : never;
// ^^^^^ never keys are pruned
}[keyof T]; // index into the mapped type to get the union of non-never keysinterfaceUserRecord {
id: number;
name: string;
email: string;
age: number;
isAdmin: boolean;
createdAt: Date;
}
type StringKeys = KeysOfType<UserRecord, string>; // 'name' | 'email'
type NumberKeys = KeysOfType<UserRecord, number>; // 'id' | 'age'
type BooleanKeys = KeysOfType<UserRecord, boolean>; // 'isAdmin'// ─────────────────────────────────────────────// 4. Key remapping with 'as' (TS 4.1+) — build a setter API from a type// ─────────────────────────────────────────────typeSetters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
// 'as' clause: remaps each key K to 'set' + capitalised key name// string & K: needed because K might not be a string (could be symbol)
};
typeUserSetters = Setters<Pick<UserRecord, 'name' | 'email' | 'age'>>;
// Produces:// {// setName: (value: string) => void;// setEmail: (value: string) => void;// setAge: (value: number) => void;// }// Verify it works with a real implementationfunctioncreateUserStore(initial: Pick<UserRecord, 'name' | 'email' | 'age'>) {
const state = { ...initial };
const setters: UserSetters = {
setName: (value) => { state.name = value; },
setEmail: (value) => { state.email = value; },
setAge: (value) => { state.age = value; },
};
return { getState: () => ({ ...state }), ...setters };
}
const userStore = createUserStore({ name: 'alice', email: 'alice@dev.io', age: 28 });
userStore.setName('Alice Johnson');
userStore.setAge(29);
console.log(userStore.getState());
// { name: 'Alice Johnson', email: 'alice@dev.io', age: 29 }
Interviewers love asking why type Test<T> = T extends string ? true : false gives true | false for T = string | number. The answer is distributivity — TypeScript maps the conditional over each union member independently, then unions the results. Mention that wrapping in a tuple ([T] extends [string]) disables this, and you'll stand out immediately.
📊 Production Insight
Distributive conditional types are great — until they aren't. A common production pitfall: writing type Filter<T, U> = T extends U ? T : never and passing a union for T. It distributes correctly, but if you then try to use the result with extends never, you get never because the union distributes to never on every member. That's a silent bug that propagates as never through the entire type chain.
Key remapping with as can produce type names that collide with string methods or reserved words. TypeScript doesn't validate the remapped key names beyond string literal uniqueness. Use Capitalize carefully — it doesn't handle multi-word keys well.
🎯 Key Takeaway
Distribution is the default for naked type parameters.
Wrap in [T] to treat the union as a single value.
Key remapping with as is powerful but needs string & K to avoid symbol collisions.
Distribution On or Off?
IfYou want to check each member of a union individually (e.g., filter types)
→
UseKeep distribution on — use naked T in the condition.
IfYou want to check the entire union as one type (e.g., 'is this union entirely strings?')
→
UseWrap in tuple: [T] extends [string] turns distribution off.
IfYou're remapping keys and don't want the template literal to break on numeric keys
→
UseAdd string & K to exclude numbers and symbols from the as clause.
Production Patterns — Generic Classes, Higher-Kinded Workarounds, and Performance
Generic classes are where most developers first encounter variance problems. TypeScript's type system is structurally typed and uses bivariant function parameters by default (strict mode makes method parameters contravariant), which means a Repository<Dog> may or may not be assignable to Repository<Animal> depending on what methods it exposes and how they use the type parameter. Getting this wrong silently breaks type safety in service layers.
TypeScript doesn't natively support higher-kinded types (HKTs) — you can't write F<T> where F itself is a type-level variable. Libraries like fp-ts work around this with an interface merging pattern called 'type class encoding'. Understanding the workaround — using a URI map and a HKT interface — is genuinely useful in functional programming codebases and shows up in interviews at companies using fp-ts.
On the performance side, deeply recursive generic types are the most common source of 'Type instantiation is excessively deep' errors in production. TypeScript has a recursion depth limit (around 100 levels for conditional types). The fix is almost always to restructure the recursion using tail-recursive conditional types, which TypeScript optimises differently, or to cap depth explicitly with a tuple-counting accumulator pattern.
Finally, type-level computation has real compiler performance costs. A conditional type that distributes over a large union (50+ members) can make tsc measurably slower. Profiling with tsc --diagnostics and --extendedDiagnostics shows type instantiation counts, which is the right metric to watch in large codebases.
productionGenericPatterns.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
// ─────────────────────────────────────────────// 1. Generic Repository class — variance done right// ─────────────────────────────────────────────interfaceEntity {
id: number;
}
interfaceReadRepository<T extendsEntity> {
findById(id: number): Promise<T | null>;
findAll(): Promise<T[]>;
}
interfaceWriteRepository<T extendsEntity> {
save(entity: T): Promise<T>;
delete(id: number): Promise<void>;
}
// Splitting read/write enables covariant usage:// ReadRepository<Dog> IS assignable to ReadRepository<Entity> safelyclassInMemoryRepository<T extendsEntity>
implementsReadRepository<T>, WriteRepository<T>
{
private store = newMap<number, T>();
asyncfindById(id: number): Promise<T | null> {
returnthis.store.get(id) ?? null;
}
asyncfindAll(): Promise<T[]> {
returnArray.from(this.store.values());
}
asyncsave(entity: T): Promise<T> {
this.store.set(entity.id, entity);
return entity;
}
asyncdelete(id: number): Promise<void> {
this.store.delete(id);
}
}
interfaceProductextendsEntity {
name: string;
priceInCents: number;
inStock: boolean;
}
const productRepo = newInMemoryRepository<Product>();
// ─────────────────────────────────────────────// 2. Depth-limited recursive generic (avoids 'excessively deep' error)// ─────────────────────────────────────────────// Without depth limit this would crash tsc on deeply nested objectstypeDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];
// ^ The tuple index acts as a counter — Depth[5] = 4, Depth[1] = 0typeDeepReadonly<T, D extendsnumber = 10> =
[D] extends [0]
? T // depth limit hit — stop recursing
: T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U, Depth[D]>> // handle arrays
: T extendsobject
? { readonly [K in keyof T]: DeepReadonly<T[K], Depth[D]> } // handle objects
: T; // primitive — return as-istypeImmutableConfig = DeepReadonly<AppConfig>;
// Every nested property is now readonly, recursively, up to 10 levels deep// ─────────────────────────────────────────────// 3. Builder pattern with generics — accumulate type state// ─────────────────────────────────────────────// The builder tracks which fields have been set at the TYPE level// Only builds when all required fields are presentclassQueryBuilder<T extendsRecord<string, unknown>, TSelectedextends keyof T = never> {
private filters: Partial<T> = {};
private selectedKeys: TSelected[] = [];
where<K extends keyof T>(key: K, value: T[K]): QueryBuilder<T, TSelected> {
this.filters[key] = value;
returnthis;
}
select<K extends keyof T>(
...keys: K[]
): QueryBuilder<T, TSelected | K> { // TSelected grows with each callthis.selectedKeys = [...this.selectedKeys, ...keys] as (TSelected | K)[];
returnthisas unknown asQueryBuilder<T, TSelected | K>;
}
// build() returns only the selected fields — typed preciselybuild(): Pick<T, TSelected> {
const result = {} asPick<T, TSelected>;
for (const key ofthis.selectedKeys) {
if (key inthis.filters) {
(result asRecord<string, unknown>)[key asstring] = this.filters[key];
}
}
return result;
}
}
interfaceOrderRecordextendsEntity {
customerId: number;
totalCents: number;
status: 'pending' | 'shipped' | 'delivered';
createdAt: Date;
}
const query = newQueryBuilder<OrderRecord>()
.where('status', 'shipped')
.select('id', 'customerId', 'totalCents')
.build();
// query is typed as: Pick<OrderRecord, 'id' | 'customerId' | 'totalCents'>// — accessing query.status would be a compile error ✓asyncfunctionrunDemo() {
await productRepo.save({ id: 1, name: 'TypeScript Handbook', priceInCents: 2999, inStock: true });
await productRepo.save({ id: 2, name: 'Clean Code', priceInCents: 3499, inStock: false });
const allProducts = await productRepo.findAll();
console.log('All products:', allProducts.map(p => p.name));
const found = await productRepo.findById(1);
console.log('Found:', found?.name, '— Price:', found?.priceInCents);
await productRepo.delete(2);
const remaining = await productRepo.findAll();
console.log('After delete:', remaining.map(p => p.name));
}
runDemo();
// TypeScript interface AppConfig (referenced in DeepReadonly example)interfaceAppConfig {
server: { host: string; port: number; ssl: boolean; };
database: { url: string; poolSize?: number; };
featureFlags?: { darkMode: boolean; betaAccess: boolean; };
}
Output
All products: [ 'TypeScript Handbook', 'Clean Code' ]
The error 'Type instantiation is excessively deep and possibly infinite' doesn't always appear locally — it depends on how your code is composed. It often only surfaces in CI after a merge that combines two perfectly valid files. Run tsc --diagnostics as part of your build pipeline and set an alert if instantiationCount climbs above 500,000. That number is your canary.
📊 Production Insight
In production, the 'excessively deep' error hits most often in generic React components with nested generics, like HOCs with Omit<Props, 'onChange'> and multiple extends clauses. The fix: flatten generics, reduce conditional type nesting, and use the depth-counter pattern shown above.
Another real-world pain: bivariance in method parameters. If you have class Box<T> { set(t: T): void }, then Box<Dog> is assignable to Box<Animal> — because set is bivariant for T. That means you can pass a Cat into a Box<Dog>. TypeScript 5.x's strict mode doesn't fix this for classes; you must explicitly use function property syntax to get strict contravariance.
🎯 Key Takeaway
Generic classes with methods are bivariant by default — Box<Dog> can accept Animal.
Use function properties for strict contravariance.
Depth-limit recursion with a tuple counter to avoid the 'excessively deep' error.
Choosing Between Class Methods and Function Properties for Variance
IfYou need the generic class to be covariant in T (e.g., read-only access)
→
UseUse only read methods. Make T appear only in return positions.
IfYou need the class to be invariant (safe for both read and write)
→
UseKeep the default method syntax — TypeScript treats methods as bivariant, which allows both read and write but may allow unsafe writes.
UseDefine the method as a function property: set: (value: T) => void. This uses the strict function comparison rules.
Branded Types, Phantom Types, and Opaque Type Aliases
TypeScript's structural type system is a double-edged sword. On one hand, it means you don't need explicit inheritance to substitute types. On the other, it lets you accidentally pass a UserID where an OrderID is expected — because both are just string underneath. Branded types solve this by adding a unique phantom property that exists only at the type level and disappears at runtime.
The technique: intersect your base type with { readonly __brand: unique symbol } where unique symbol ensures every brand is distinct. The readonly prevents accidental assignment. The result: the compiler enforces type distinctions that don't exist in the runtime value. This is pure type-level safety with zero runtime cost.
Phantom types take the idea further. A phantom type parameter is one that appears in the type declaration but is never used in the actual runtime structure. It's used purely to encode additional type-level state — like tracking whether a user input has been validated, or whether a database connection is open or closed. The type parameter exists only at compile time, guiding what operations are allowed.
In production codebases, branded types are the single most impactful pattern for preventing ID confusion, unit mix-ups (e.g., meters vs feet), and state-machine transitions. They're also a favourite interview topic because they demonstrate a deep understanding of TypeScript's type system beyond the basics.
brandedTypes.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// ─────────────────────────────────────────────// 1. Branded type — unique marker at the type level// ─────────────────────────────────────────────typeBrand<T, BrandTextendsstring> = T & { readonly __brand: BrandT };
// Use 'unique symbol' to create truly distinct brandsdeclareconstUserIDBrand: unique symbol;
typeUserID = Brand<string, typeofUserIDBrand>;
declareconstOrderIDBrand: unique symbol;
typeOrderID = Brand<string, typeofOrderIDBrand>;
// Functions that accept only the branded typefunctionfindUser(id: UserID): User { /* ... */ return {}; }
functionfindOrder(id: OrderID): Order { /* ... */ return {}; }
// At runtime, both are just strings — but the compiler prevents mixing themconst uid = 'user-123'asUserID;
const oid = 'order-456'asOrderID;
findUser(uid); // ✓ okfindUser(oid); // ✗ Error: Type 'OrderID' is not assignable to type 'UserID'// ─────────────────────────────────────────────// 2. Phantom type — parameter never used in runtime value// ─────────────────────────────────────────────// Represents a state machine: StringInput<Validated> vs StringInput<Raw>declareconstValidated: unique symbol;
declareconstRaw: unique symbol;
typeValidationState = typeofValidated | typeofRaw;
classStringInput<StateextendsValidationState = Raw> {
constructor(public value: string) {}
// Only allowed when state is Rawvalidate(): StringInput<typeofValidated> {
if (this.value.length === 0) thrownewError('Empty string');
returnnewStringInput<typeofValidated>(this.value);
}
// Only allowed when state is ValidatedgetLength(): number {
returnthis.value.length;
}
}
const raw = newStringInput('hello');
// raw.getLength(); // ✗ Error: Property 'getLength' does not exist on type 'StringInput<"Raw">'const validated = raw.validate();
console.log(validated.getLength()); // ✓ ok — type is now StringInput<"Validated">// ─────────────────────────────────────────────// 3. Enforcing unit distinctions (e.g., feet vs meters)// ─────────────────────────────────────────────typeMeters = Brand<number, 'meters'>;
typeFeet = Brand<number, 'feet'>;
functioncalculateArea(width: Meters, height: Meters): Meters {
return (width * height) asMeters;
}
const w = 10asMeters;
const h = 15asFeet;
calculateArea(w, w); // ✓ okcalculateArea(w, h); // ✗ Error: 'Feet' not assignable to 'Meters'
Branded types are like putting a sticky note on a value that says 'this is a UserID'. The note is invisible at runtime, but the compiler reads it and stops you from handing a sticky note saying 'OrderID' where a 'UserID' is expected.
The __brand property never exists at runtime — it's erased during compilation.
unique symbol ensures that each Brand call creates a distinct brand, even if the string name collides.
Branded types add zero runtime cost — no memory, no coercion, no prototype pollution.
Phantom type parameters let you encode state (Validated/Raw, Open/Closed) without affecting the runtime type.
Use branded types heavily in API boundaries where ID types are your most common source of bugs.
📊 Production Insight
The biggest myth about branded types: 'they slow down the compiler'. They don't. A branded type is just an intersection with an object literal — the compiler handles them trivially. What does slow down compilation is distributing conditional types over large unions of branded types. Keep your branded types simple: just Brand<T, symbol>.
In a real production incident, an engineer passed a number (price in cents) to a function expecting a number (quantity). The unit mismatch caused a 23% revenue undercharge for a week. Branded types for Cents and Quantity would have caught it at compile time. Use them for any numeric field that has a unit — milliseconds, bytes, pixels, dollars.
🎯 Key Takeaway
Branded types are zero-cost type-level protection — use them for IDs, units, and state machines.
The phantom type parameter pattern lets you encode compile-time state without runtime overhead.
If you can mix up two values that share the same structure, you need a brand.
Branded Type or Just a Type Alias?
IfYou have two types that share the same primitive (e.g., string IDs) but must not be mixed
→
UseUse a branded type: type UserID = Brand<string, 'user'>.
IfYou want to enforce a state machine (e.g., Raw -> Validated -> Processed)
→
UseUse a phantom type parameter on a class or type that tracks the state.
IfYou just need a descriptive alias for documentation
→
UseUse a plain type alias: type UserID = string. No compile-time protection, but self-documenting.
Generic Constraints Are Not Just for Strings and Numbers — Use Them to Enforce Shapes
Every tutorial shows you how to constrain a generic with extends string or extends number. That’s kindergarten stuff. Real power comes from constraining generics to interfaces or structural shapes. You don’t care what exact type is passed, but you need to guarantee it has a .length or a .name property. That’s where bounded type parameters earn their keep. Instead of writing overloads for every possible type, you define a single generic function that works with any type satisfying a structural contract. TypeScript then infers the specific type and preserves it all the way through. The compiler becomes your best friend, not your enemy. Stop fighting types. Make them work for you.
constrain-by-shape.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interfaceHasLength {
length: number;
}
function logLength<T extendsHasLength>(arg: T): T {
console.log(`Length: ${arg.length}`);
return arg;
}
const result = logLength("hello"); // Type: "hello" (literal, not string)
console.log(result.toUpperCase());
logLength([1, 2, 3]); // Works// logLength(42); // Error: number doesn't have .length
Never constrain to any or object as a shortcut. You lose all type safety. Always prefer a granular shape constraint. Your future self debugging a cryptic any leak will thank you.
🎯 Key Takeaway
Constrain generics by shape, not by specific type. Let the compiler infer the rest.
thecodeforge.io
Typescript Generics
Mapped Types with Generics Are the Only Way to Type Objects Dynamically — Stop Writing Repetitive Interfaces
Hand-crafting a new interface for every possible object shape is a maintenance nightmare. That’s why TypeScript gave us mapped types. Combine them with generics, and you transform one object type into another without losing a single type relationship. Want to make every property optional? Partial<T>. Want to make every property readonly? Readonly<T>. But that’s just the beginning. You can build custom mapped types that transform keys, values, or both. This isn’t academic. If you’re building form state, API mappers, or configuration objects, mapped types eliminate boilerplate and prevent typos. The compiler validates your entire data pipeline at compile time. No runtime surprises.
Combine mapped types with conditional types to filter or modify keys based on value types. Example: PickByValue<T, string> extracts only properties whose values are strings. That’s pure gold for serialization logic.
🎯 Key Takeaway
Mapped types + generics = one source of truth. No more hand-copying interface fields.
● Production incidentPOST-MORTEMseverity: high
The Silent `any` That Sneaked Past Code Review
Symptom
A seemingly safe merge(defaults, overrides) returned any instead of the expected union type. No compile errors appeared — just any flowing downstream.
Assumption
The team assumed that a generic function <T, U>(a: T, b: U): T & U would always produce the intersection type, even when the arguments had overlapping but incompatible property types.
Root cause
TypeScript's type inference cannot always resolve the intersection when the types are complex. The inference fell back to unknown for both type parameters, and the intersection collapsed to any because of a previous design decision in the library's base type definition.
Fix
Replaced the naive generic with a T extends Record<string, unknown> constraint and a mapped type that explicitly merges properties. Added a conditional type to preserve never for conflicting keys.
Key lesson
Never trust inference for complex union/intersection operations — hover the return type in your IDE before merging.
Use satisfies operator or explicit type arguments at call sites to force the compiler to verify your expectations.
Add a no-op test that asserts the return type is assignable to a concrete type — type Check = Expect<Equal<typeof result, Expected>>.
Production debug guideWhen the compiler can't figure out T, here's how to find out why4 entries
Symptom · 01
Generic function returns unknown when called without explicit type arguments
→
Fix
Check if the type parameter is used only in the return type, not in any argument. Inference only works from arguments. Either add a dummy argument or provide an explicit <T>.
Symptom · 02
Hover tooltip shows T extends unknown when you expect a concrete type
→
Fix
Look for a missing constraint. Add extends to the type parameter to give the compiler a hint. If the parameter has no constraint, TypeScript won't infer the exact shape.
Symptom · 03
Distributive conditional type produces an unexpected union
→
Fix
If you want to check the entire union as a single type, wrap the condition in a tuple: [T] extends [string] ? true : false. This disables distribution.
Symptom · 04
"Type instantiation is excessively deep" error in CI
→
Fix
Use a depth counter pattern (tuple index) to limit recursion. Also check for circular type references in your mapped types.
★ Bread-and-Butter Commands for Generic DebuggingWhen the type system says no, these are your first moves.
Inference doesn't work — return type is `unknown`−
Immediate action
Add a dummy argument that uses the type parameter
Commands
Check if the parameter appears in the argument list at all
Hover the type at the call site
Fix now
Provide explicit type argument: myFunction<MyType>(arg) replaces inference
Conditional type distributes when you didn't want it to+
Immediate action
Wrap the checked type in a tuple
Commands
Change `T extends U ? A : B` to `[T] extends [U] ? A : B`
Verify the hover value shows the expected single type, not a union
Fix now
Add the tuple wrapper — that's the only reliable way to stop distribution
"Type instantiation is excessively deep"+
Immediate action
Add a depth-limit parameter using a tuple counter
Commands
Use a `Depth` tuple: `type Depth = [never, 0, 1, ...]` and index into it
Reduce recursion in your mapped types — inline simpler logic
Fix now
Cap depth to 10 and make the type return unknown for deeper levels
Pattern
Type Safety
Flexibility
Inference Quality
Best For
Unconstrained Generic <T>
High — preserves exact type
Maximum
Excellent for identity functions
Wrappers, containers, identity transforms
Constrained Generic <T extends Shape>
High — enforces minimum shape
High within bounds
Excellent — T stays specific
Utilities that need to access properties
keyof T + T[K] lookup
Very High — property-level safety
Medium
Excellent — return type is exact
Dynamic property access, setters/getters
Conditional Type T extends U ? A : B
Very High
High — compute types from structure
Deferred on free T, eager otherwise
Utility types, type-level branching
Mapped Type { [K in keyof T]: ... }
Very High
High — transforms all properties
Good — depends on value expression
DeepPartial, Readonly, key remapping
Branded Type T & { __brand: symbol }
Very High — prevents primitive mixing
None — restricts type strictly
N/A — built from primitive
IDs, units, state machines, opaque types
Plain any
None — opt out of type system
Total
None
Third-party JS interop only — last resort
⚙ Quick Reference
7 commands from this guide
File
Command / Code
Purpose
genericInference.ts
function wrapInArray(value: T): T[] {
How TypeScript Actually Infers Generic Type Parameters
constrainedGenerics.ts
interface HasLength {
Constraints, keyof, and the Power of Bounded Type Parameters
utilityTypesFromScratch.ts
type DeepPartial = {
Conditional and Mapped Types
productionGenericPatterns.ts
interface Entity {
Production Patterns
brandedTypes.ts
type Brand = T & { readonly __brand: BrandT };
Branded Types, Phantom Types, and Opaque Type Aliases
constrain-by-shape.ts
interface HasLength {
Generic Constraints Are Not Just for Strings and Numbers
mapped-generic.ts
type Nullable = {
Mapped Types with Generics Are the Only Way to Type Objects Dynamically
Key takeaways
1
Type argument inference works from arguments to parameters
not from return types. When inference fails, TypeScript widens silently.
2
A constraint (extends) is a lower bound
T stays specific unless you explicitly erase it.
3
Conditional types distribute over unions by default. Wrap in [T] to check the entire union as a single value.
4
Key remapping with as in mapped types requires string & K to exclude non-string keys.
5
Branded types provide zero-cost compile-time protection against mixing primitive IDs and units.
6
Depth-limit recursive generics with a tuple counter to avoid the 'excessively deep' error in CI.
7
The number one cause of silent any in generic utility libraries is a missing constraint on the type parameter.
Common mistakes to avoid
5 patterns
×
Not providing an explicit type argument when inference fails
Symptom
The return type of a generic function becomes unknown or an overly broad union, but no compile error is shown. Downstream code uses the returned value without verifying the type.
Fix
Provide an explicit type argument at the call site, e.g., myFunc<MyType>(arg). Alternatively, add a constraint that limits the type parameter to prevent widening.
×
Using `extends` constraint but expecting it to widen the type
Symptom
A function declared as fn<T extends Entity>(x: T): T is called, and the return type is the exact subtype (e.g., Dog), but the developer expected Entity. They then use x in a place that requires Entity and get a compile error.
Fix
Understand that a constraint is a lower bound — T stays specific. If you want the broad type, use fn(x: Entity): Entity instead of a generic.
×
Assuming `keyof` only returns string keys
Symptom
A generic property getter works for objects with string keys but fails or returns unexpected types for objects with numeric index signatures (e.g., arrays).
Fix
Use keyof T & string to exclude number and symbol keys when you only want string keys. For arrays, consider using a separate generic overload.
×
Creating deeply recursive generic types without a depth limit
Symptom
The error 'Type instantiation is excessively deep and possibly infinite' appears sporadically, often only in CI after a merge.
Fix
Add a depth-counter parameter using a tuple index (as shown in the depth-limited DeepReadonly example). Cap the recursion at a reasonable depth (10-20).
×
Not using branded types for primitive IDs
Symptom
Runtime bugs where a UserID string is accidentally passed to a function expecting an OrderID, causing data corruption or security issues.
Fix
Define branded types for each ID type using Brand<T, unique symbol>. The compile-time check eliminates the entire class of bugs.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Why does `function identity(arg: T): T { return arg; }` not allow `T`...
Q02SENIOR
Explain what happens when you pass a union to a distributive conditional...
Q03SENIOR
How would you build a type-safe builder pattern where the return type na...
Q01 of 03SENIOR
Why does `function identity(arg: T): T { return arg; }` not allow `T` to be inferred from the return type?
ANSWER
TypeScript's type argument inference works in one direction: from arguments to parameters. The return type is a destination, not a source. Inference cannot start from the return type because at the point of call, the expected return type doesn't constrain the argument. If you need inference from the return type, you must use explicit type argument or a function overload that uses the return type as a discriminator.
Q02 of 03SENIOR
Explain what happens when you pass a union to a distributive conditional type. How do you disable distribution?
ANSWER
When a conditional type's checked type is a naked type parameter, TypeScript distributes the condition over each member of the union. So type IsString<T> = T extends string ? true : false applied to string | number produces true | false. To disable distribution, wrap the type parameter in a tuple: type IsString<T> = [T] extends [string] ? true : false. This checks the entire union as a single tuple type, returning false because [string | number] is not assignable to [string].
Q03 of 03SENIOR
How would you build a type-safe builder pattern where the return type narrows down the shape as methods are called in a specific order?
ANSWER
Use a generic class with an accumulator type parameter that tracks which methods have been called. Each method returns a new instance with an updated type parameter (union with the key). The final build() method returns Pick<T, SelectedKeys>. For example: class Builder<T, S extends keyof T = never> where S grows via select<K extends keyof T>(...keys: K[]): Builder<T, S | K>. This ensures that only selected fields appear in the result, and accessing unselected fields is a compile error. See the QueryBuilder example in the article.
01
Why does `function identity(arg: T): T { return arg; }` not allow `T` to be inferred from the return type?
SENIOR
02
Explain what happens when you pass a union to a distributive conditional type. How do you disable distribution?
SENIOR
03
How would you build a type-safe builder pattern where the return type narrows down the shape as methods are called in a specific order?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
When should I use a generic constraint vs. a simple interface parameter?
Use a generic constraint when you need the exact type of the argument preserved in the return type or in other parameters. Use a simple interface parameter when you only need to access properties of that shape and don't need the specific subtype identity. For example: function log<T extends HasLength>(x: T): T preserves the exact type; function log(x: HasLength): HasLength erases it.
Was this helpful?
02
Why does my conditional type sometimes show `T extends ...` in the hover instead of a resolved type?
That's called deferred inference. The type parameter T is still free (not resolved) at the point where the conditional type is evaluated. This happens when the conditional type is used inside a generic function body where T is a parameter. The compiler cannot evaluate the condition until T is concrete at the call site. The hover text is telling you the condition is still pending. To see the resolved type, provide an explicit type argument or hover over a concrete usage.
Was this helpful?
03
What's the practical difference between `type A = T & { __brand: ... }` and using a class wrapper?
The branded intersection is zero-cost — it disappears at runtime and doesn't affect the value. A class wrapper allocates an object at runtime and requires constructor calls like new UserId('abc'). Use branded types when you want pure compile-time safety with no runtime overhead. Use a class when you need runtime behaviour (e.g., validation in the constructor).
Was this helpful?
04
How do I fix 'Type instantiation is excessively deep'?
Add a depth-limit parameter using a tuple index counter. Define a Depth tuple with never followed by numbers 0 through 20, then index into it at each recursion level. Once Depth[D] resolves to 0, stop recursing. This caps the depth at a safe number (e.g., 10) and prevents the infinite recursion error. Also consider simplifying your mapped types — reduce nesting of conditional types inside mapped types.