Senior 7 min · March 05, 2026

TypeScript Generics — The Silent `any` in Merge Functions

A merge(defaults, overrides) silently returns any past production code review.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
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.

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.

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> needed
function 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 union
function pickFirst<T>(a: T, b: T): T {
  return a;
}

// Both args must agree on T — TypeScript picks the wider type
const result = pickFirst('hello', 42);
//    ^ Error: Argument of type 'number' is not assignable to parameter of type 'string'
// Fix: be explicit when you WANT a union
function 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 assignment
const numbers = [1, 2, 3];

// ✓ TypeScript infers T = number from the array's element type
const 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-assigned
function transform<T>(items: T[], fn: (item: T) => T): T[] {
  return items.map(fn);
}

// ✓ Inline: TypeScript infers T = number
const r1 = transform([1, 2, 3], n => n * 2);

// ✓ Pre-assigned BUT explicitly typed: still works
const 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 ]
Output
[ 'hello' ]
[ 42 ]
[ 2, 4, 6 ]
[ 2, 4, 6 ]
Watch Out: Widening on Inference
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
// ─────────────────────────────────────────────
interface HasLength {
  length: number;
}

function logLengthAndReturn<T extends HasLength>(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, TKey extends keyof TObject>(
  obj: TObject,
  key: TKey
): TObject[TKey] {                      // return type is the EXACT type of that property
  return obj[key];
}

const userProfile = {
  id: 101,
  username: 'alice_dev',
  joinedAt: new Date('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 Promise
type Awaited2<T> = T extends Promise<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 signature
type FirstParam<TFn extends (...args: any[]) => any> =
  TFn extends (first: infer P, ...rest: any[]) => any ? P : never;

type FetchFn       = (url: string, retries: number) => Promise<Response>;
type FetchFirstArg = FirstParam<FetchFn>;        // string ✓

// Extract element type from an array
type ArrayElement<TArr extends readonly unknown[]> =
  TArr extends readonly (infer Element)[] ? Element : never;

const statusCodes = [200, 201, 400, 404, 500] as const;
type StatusCode = ArrayElement<typeof statusCodes>; // 200 | 201 | 400 | 404 | 500

// ─────────────────────────────────────────────
// 4. Constraint preservation: T stays specific, not widened
// ─────────────────────────────────────────────
function mergeWithDefaults<T extends Record<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 }
Output
Length: 20
Length: 5
101
alice_dev
{ timeout: 5000, retries: 3, verbose: false }
Pro Tip: Constraint vs. Parameter Type
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.

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 +?
type DeepPartial<T> = {
  [K in keyof T]+?: T[K] extends object ? DeepPartial<T[K]> : T[K];
  //          ^^^ +? makes every key optional, recursing into nested objects
};

type StrictRequired<T> = {
  [K in keyof T]-?: T[K];  // -? strips the optional modifier from every key
};

interface AppConfig {
  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 patches
const 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 unions
type IsString<T> = T extends string ? 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 distribution
type IsExactlyString<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 ValueType
type KeysOfType<T, ValueType> = {
  [K in keyof T]: T[K] extends ValueType ? K : never;
  //                                            ^^^^^ never keys are pruned
}[keyof T];  // index into the mapped type to get the union of non-never keys

interface UserRecord {
  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
// ─────────────────────────────────────────────

type Setters<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)
};

type UserSetters = 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 implementation
function createUserStore(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 }
Output
{ name: 'Alice Johnson', email: 'alice@dev.io', age: 29 }
Interview Gold: Distributive Conditional Types
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
// ─────────────────────────────────────────────

interface Entity {
  id: number;
}

interface ReadRepository<T extends Entity> {
  findById(id: number): Promise<T | null>;
  findAll(): Promise<T[]>;
}

interface WriteRepository<T extends Entity> {
  save(entity: T): Promise<T>;
  delete(id: number): Promise<void>;
}

// Splitting read/write enables covariant usage:
// ReadRepository<Dog> IS assignable to ReadRepository<Entity> safely
class InMemoryRepository<T extends Entity>
  implements ReadRepository<T>, WriteRepository<T>
{
  private store = new Map<number, T>();

  async findById(id: number): Promise<T | null> {
    return this.store.get(id) ?? null;
  }

  async findAll(): Promise<T[]> {
    return Array.from(this.store.values());
  }

  async save(entity: T): Promise<T> {
    this.store.set(entity.id, entity);
    return entity;
  }

  async delete(id: number): Promise<void> {
    this.store.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  priceInCents: number;
  inStock: boolean;
}

const productRepo = new InMemoryRepository<Product>();

// ─────────────────────────────────────────────
// 2. Depth-limited recursive generic (avoids 'excessively deep' error)
// ─────────────────────────────────────────────

// Without depth limit this would crash tsc on deeply nested objects
type Depth = [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] = 0

type DeepReadonly<T, D extends number = 10> =
  [D] extends [0]
    ? T                                           // depth limit hit — stop recursing
    : T extends (infer U)[]
    ? ReadonlyArray<DeepReadonly<U, Depth[D]>>     // handle arrays
    : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K], Depth[D]> }  // handle objects
    : T;                                          // primitive — return as-is

type ImmutableConfig = 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 present
class QueryBuilder<T extends Record<string, unknown>, TSelected extends 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;
    return this;
  }

  select<K extends keyof T>(
    ...keys: K[]
  ): QueryBuilder<T, TSelected | K> {    // TSelected grows with each call
    this.selectedKeys = [...this.selectedKeys, ...keys] as (TSelected | K)[];
    return this as unknown as QueryBuilder<T, TSelected | K>;
  }

  // build() returns only the selected fields — typed precisely
  build(): Pick<T, TSelected> {
    const result = {} as Pick<T, TSelected>;
    for (const key of this.selectedKeys) {
      if (key in this.filters) {
        (result as Record<string, unknown>)[key as string] = this.filters[key];
      }
    }
    return result;
  }
}

interface OrderRecord extends Entity {
  customerId: number;
  totalCents: number;
  status: 'pending' | 'shipped' | 'delivered';
  createdAt: Date;
}

const query = new QueryBuilder<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 ✓

async function runDemo() {
  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)
interface AppConfig {
  server: { host: string; port: number; ssl: boolean; };
  database: { url: string; poolSize?: number; };
  featureFlags?: { darkMode: boolean; betaAccess: boolean; };
}
Output
All products: [ 'TypeScript Handbook', 'Clean Code' ]
Found: TypeScript Handbook — Price: 2999
After delete: [ 'TypeScript Handbook' ]
Watch Out: Type Instantiation Depth in CI
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.
IfYou want strict contravariance (safe writes only)
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
// ─────────────────────────────────────────────

type Brand<T, BrandT extends string> = T & { readonly __brand: BrandT };

// Use 'unique symbol' to create truly distinct brands
declare const UserIDBrand: unique symbol;
type UserID = Brand<string, typeof UserIDBrand>;

declare const OrderIDBrand: unique symbol;
type OrderID = Brand<string, typeof OrderIDBrand>;

// Functions that accept only the branded type
function findUser(id: UserID): User { /* ... */ return {}; }
function findOrder(id: OrderID): Order { /* ... */ return {}; }

// At runtime, both are just strings — but the compiler prevents mixing them
const uid = 'user-123' as UserID;
const oid = 'order-456' as OrderID;

findUser(uid);   // ✓ ok
findUser(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>
declare const Validated: unique symbol;
declare const Raw: unique symbol;

type ValidationState = typeof Validated | typeof Raw;

class StringInput<State extends ValidationState = Raw> {
  constructor(public value: string) {}

  // Only allowed when state is Raw
  validate(): StringInput<typeof Validated> {
    if (this.value.length === 0) throw new Error('Empty string');
    return new StringInput<typeof Validated>(this.value);
  }

  // Only allowed when state is Validated
  getLength(): number {
    return this.value.length;
  }
}

const raw = new StringInput('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)
// ─────────────────────────────────────────────

type Meters = Brand<number, 'meters'>;
type Feet   = Brand<number, 'feet'>;

function calculateArea(width: Meters, height: Meters): Meters {
  return (width * height) as Meters;
}

const w = 10 as Meters;
const h = 15 as Feet;

calculateArea(w, w);   // ✓ ok
calculateArea(w, h);   // ✗ Error: 'Feet' not assignable to 'Meters'
Mental Model: The Type-Level Sticky Note
  • 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.
● 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
PatternType SafetyFlexibilityInference QualityBest For
Unconstrained Generic <T>High — preserves exact typeMaximumExcellent for identity functionsWrappers, containers, identity transforms
Constrained Generic <T extends Shape>High — enforces minimum shapeHigh within boundsExcellent — T stays specificUtilities that need to access properties
keyof T + T[K] lookupVery High — property-level safetyMediumExcellent — return type is exactDynamic property access, setters/getters
Conditional Type T extends U ? A : BVery HighHigh — compute types from structureDeferred on free T, eager otherwiseUtility types, type-level branching
Mapped Type { [K in keyof T]: ... }Very HighHigh — transforms all propertiesGood — depends on value expressionDeepPartial, Readonly, key remapping
Branded Type T & { __brand: symbol }Very High — prevents primitive mixingNone — restricts type strictlyN/A — built from primitiveIDs, units, state machines, opaque types
Plain anyNone — opt out of type systemTotalNoneThird-party JS interop only — last resort

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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
When should I use a generic constraint vs. a simple interface parameter?
02
Why does my conditional type sometimes show `T extends ...` in the hover instead of a resolved type?
03
What's the practical difference between `type A = T & { __brand: ... }` and using a class wrapper?
04
How do I fix 'Type instantiation is excessively deep'?
🔥

That's TypeScript. Mark it forged?

7 min read · try the examples if you haven't

Previous
TypeScript Classes and OOP
4 / 15 · TypeScript
Next
TypeScript with React