TypeScript Generics — The Silent `any` in Merge Functions
A merge(defaults, overrides) silently returns any past production code review.
- 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
extendspreserve 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
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.
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.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.unknown or a union you didn't expectT extends ... literally<Type> at the call site or ensure the context constrains 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.
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.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.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.infer lets you extract type components at compile time — use it to build your own utility types..length)<T extends { length: number }> preserves the exact type.fn(x: Shape): string is cleaner.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.
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.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.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.[T] to treat the union as a single value.as is powerful but needs string & K to avoid symbol collisions.T in the condition.[T] extends [string] turns distribution off.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.
tsc --diagnostics as part of your build pipeline and set an alert if instantiationCount climbs above 500,000. That number is your canary.Omit<Props, 'onChange'> and multiple extends clauses. The fix: flatten generics, reduce conditional type nesting, and use the depth-counter pattern shown above.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.Box<Dog> can accept Animal.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.
- The
__brandproperty never exists at runtime — it's erased during compilation. unique symbolensures that eachBrandcall 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.
Brand<T, symbol>.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.type UserID = Brand<string, 'user'>.type UserID = string. No compile-time protection, but self-documenting.The Silent `any` That Sneaked Past Code Review
merge(defaults, overrides) returned any instead of the expected union type. No compile errors appeared — just any flowing downstream.<T, U>(a: T, b: U): T & U would always produce the intersection type, even when the arguments had overlapping but incompatible property types.unknown for both type parameters, and the intersection collapsed to any because of a previous design decision in the library's base type definition.T extends Record<string, unknown> constraint and a mapped type that explicitly merges properties. Added a conditional type to preserve never for conflicting keys.- Never trust inference for complex union/intersection operations — hover the return type in your IDE before merging.
- Use
satisfiesoperator 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>>.
unknown when called without explicit type arguments<T>.T extends unknown when you expect a concrete typeextends to the type parameter to give the compiler a hint. If the parameter has no constraint, TypeScript won't infer the exact shape.[T] extends [string] ? true : false. This disables distribution.myFunction<MyType>(arg) replaces inferenceKey takeaways
extends) is a lower bound[T] to check the entire union as a single value.as in mapped types requires string & K to exclude non-string keys.any in generic utility libraries is a missing constraint on the type parameter.Common mistakes to avoid
5 patternsNot providing an explicit type argument when inference fails
unknown or an overly broad union, but no compile error is shown. Downstream code uses the returned value without verifying the type.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
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.fn(x: Entity): Entity instead of a generic.Assuming `keyof` only returns string keys
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
DeepReadonly example). Cap the recursion at a reasonable depth (10-20).Not using branded types for primitive IDs
Brand<T, unique symbol>. The compile-time check eliminates the entire class of bugs.Interview Questions on This Topic
Why does `function identity
Frequently Asked Questions
That's TypeScript. Mark it forged?
7 min read · try the examples if you haven't