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.

7 min read

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:

typescripttypescript
type IsString<T> = T extends string ? "yes" : "no";
 
type Result = IsString<never>;
// never -- not "no" as you might expect

This 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:

typescripttypescript
type IsStringSafe<T> = [T] extends [string] ? "yes" : "no";
 
type Result = IsStringSafe<never>;
// "yes" -- never is assignable to everything

Only 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:

typescripttypescript
type Infinite<T> = T extends object ? Infinite<T> : T;
// This will always recurse on object types without reaching a base case

The 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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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 type

The 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:

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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 identical

Brands 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

Rune AI

Key Insights

  • never in a distributive conditional type produces never because there are no members to distribute over.
  • Recursive types need a base case, and that base case must actually be reachable.
  • infer only works inside the extends clause of a conditional type.
  • satisfies validates without widening; type annotations widen.
  • Const type parameters need readonly constraints to work correctly.
RunePowered by Rune AI

Frequently Asked Questions

Why does my conditional type return never when I pass never?

`never` is the empty union. A distributive conditional type distributes over all members -- and there are none. The result is `never` without the conditional body running at all. Wrap in `[T]` to disable distribution and treat `never` as a single type.

Why does my recursive type hit a depth limit?

TypeScript has an internal recursion depth guard. Check that every recursive path has a base case that stops the recursion, and verify that the base case is actually reachable with your input types.

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.