Advanced Type Logic Mistakes in TypeScript
Common mistakes with conditional types, infer, distributive behavior, recursive types, and branded types -- and how to fix each one.
Advanced TypeScript type mistakes come with advanced foot-guns. Here are the mistakes that trip up experienced developers and how to fix each one. The first and most surprising involves never.
Mistake 1: expecting a conditional type to fire on never
This is the most surprising mistake because it breaks an assumption every other type seems to confirm: that a conditional type always evaluates one branch or the other. When a distributive conditional type receives never, the conditional body never runs. The result is always never:
type IsString<T> = T extends string ? "yes" : "no";
type Result = IsString<never>;
// never -- not "no" as you might expectThis happens because never is the empty union. Distribution iterates over zero members, and the result of distributing over nothing is never.
The fix is to disable distribution by wrapping the checked type:
type IsStringSafe<T> = [T] extends [string] ? "yes" : "no";
type Result = IsStringSafe<never>;
// "yes" -- never is assignable to everythingOnly use this when you specifically need to handle never. For normal union filtering, the distributive behavior is correct and desirable. For more, see Distributive Conditional Types in TypeScript.
Mistake 2: recursive type without a reachable base case
A recursive type that never stops recursing hits TypeScript's internal depth limit:
type Infinite<T> = T extends object ? Infinite<T> : T;
// This will always recurse on object types without reaching a base caseThe problem is that the conditional check T extends object always succeeds because T is an object type, and the recursive call passes the exact same T back to itself. The type never makes any progress toward a simpler case. The compiler hits its internal depth limit and reports an error.
The root cause is that each recursive call must work with a strictly smaller or structurally different type than the previous call. When the input never shrinks, the recursion loops forever. The fix is to recurse on a narrower type at each step by drilling into property values: the type T[K] for each key instead of T itself.
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]> // recurses on T[K], not T
: T[K];
};Each level drills into property values, which have their own (potentially smaller) shape, so the recursion always makes progress toward a primitive.
Mistake 3: using infer outside a conditional type
Infer is only valid inside the extends clause of a conditional type. This is a syntax error:
type ElementType<T> = infer Item;
// Syntax error: 'infer' declarations are only permitted in the 'extends' clause of a conditional type.The fix is to wrap it in a conditional type, giving infer a position inside a pattern it can capture from, such as the element slot of an array:
type ElementType<T> = T extends Array<infer Item> ? Item : never;Infer only makes sense as part of a type-level pattern match, so it always needs a conditional type around it.
Mistake 4: confusing satisfies with type annotations
Satisfies validates without widening the object's structure away. Type annotations widen every property to the annotated type. Mixing up the two leads to unexpected type information loss:
const config: Record<string, unknown> = { port: 3000, host: "localhost" };
// config.port is unknown -- the annotation widened it
const config2 = { port: 3000, host: "localhost" } satisfies Record<string, unknown>;
// config2.port is number, not unknown -- satisfies kept the specific property typeThe fix is to use satisfies when you want validation with precise types, and type annotations when you intentionally want to widen. For more, see Satisfies Operator in TypeScript.
Mistake 5: const type parameter with mutable constraint
A const type parameter paired with a mutable constraint silently falls back to the constraint type:
declare function bad<const T extends string[]>(args: T): T;
bad(["a", "b"]);
// T is string[], not readonly ["a", "b"]The readonly inferred type cannot satisfy the mutable constraint, so TypeScript falls back. The fix is to use readonly constraints:
declare function good<const T extends readonly string[]>(args: T): T;
good(["a", "b"]);
// T is readonly ["a", "b"]This fallback happens silently, with no compiler warning, so it is easy to miss during code review.
Mistake 6: over-constraining branded types
Branded types add compile-time tags to primitives. A common mistake is copy-pasting the brand pattern and accidentally reusing the same tag string for two different domain types:
type Email = string & { readonly __brand: "Id" };
type Username = string & { readonly __brand: "Id" };
function sendEmail(email: Email) { /* ... */ }
const username = "alice" as Username;
sendEmail(username);
// Compiles! Both brands use the tag "Id", so they are structurally identicalBrands only prevent mixing when each domain type uses a distinct tag. Here both aliases intersect string with the exact same { readonly __brand: "Id" } shape, so TypeScript treats Email and Username as the same type and the mistake compiles silently.
The fix: give every brand a unique, descriptive tag, such as "Email" and "Username" instead of a shared generic name like "Id". For more, see Branded Types in TypeScript.
Mistake 7: expecting advanced types to work at runtime
All of these patterns (conditional types, infer, recursive types, branded types) exist only at compile time. They produce no runtime code, no runtime checks, and no runtime errors.
A branded UserId is just a string when the code runs. A recursive type alias does not create recursive runtime logic.
If you need runtime validation, pair your advanced types with runtime checks: a validation library, type guards, or hand-written validation functions. The type describes what the runtime code should enforce, but only the runtime code actually enforces it.
Rune AI
Key Insights
neverin a distributive conditional type producesneverbecause there are no members to distribute over.- Recursive types need a base case, and that base case must actually be reachable.
inferonly works inside theextendsclause of a conditional type.satisfiesvalidates without widening; type annotations widen.- Const type parameters need
readonlyconstraints to work correctly.
Frequently Asked Questions
Why does my conditional type return never when I pass never?
Why does my recursive type hit a depth limit?
Conclusion
Advanced TypeScript types are powerful but have sharp edges. Distribution over never, forgetting base cases in recursive types, misusing infer, confusing satisfies with type annotations, and over-constraining branded types are the most common pitfalls. Understanding why they happen makes the fixes straightforward.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.