Why TypeScript Does Not Narrow Generic Types Together in Conditionals

Summary

We encountered a production type-safety regression where a generic constraint failed to narrow the type of multiple parameters simultaneously during a conditional check. Despite the function signature requiring both x and y to be of the same generic type T (which is constrained to a union AB), TypeScript’s control flow analysis only narrows the specific variable being checked. This leads to a type mismatch in logic where the developer assumes y shares the same narrowed subtype as x, but the compiler treats y as the broader union.

Root Cause

The issue stems from the way TypeScript’s Control Flow Analysis (CFA) interacts with Generics.

  • Narrowing is local to the variable: When you perform if (x.type == UnionType.a), the compiler creates a new type refinement for x within that block.
  • Generics represent “any possible T”: Even though the signature f<T extends AB>(x: T, y: T) implies x and y are the same type, the compiler does not “link” the narrowing of x to y.
  • The “Identity” Problem: From the compiler’s perspective, T is an opaque type that satisfies AB. Narrowing x to A does not mathematically prove to the compiler that T itself is A, because T could technically be a more complex intersection that satisfies the constraint but behaves differently under inspection.

Why This Happens in Real Systems

In complex distributed systems or large-scale frontends, we often use Discriminated Unions to handle state transitions or API responses.

  • Pattern Matching Failures: We often write utility functions that process two related entities (like a request and its response).
  • Type Erasure in Logic: As codebases grow, developers rely on the “contract” of the generic constraint rather than the explicit reality of the runtime value.
  • Compiler Limitations: Type systems are designed to be sound, meaning they prefer to be overly cautious (and “wrong” from a human perspective) rather than allowing a potentially unsafe operation.

Real-World Impact

  • Logic Errors: Developers may access properties on y that they believe exist because they “know” it must match x, leading to undefined runtime errors.
  • Increased Boilerplate: Teams often resort to excessive type assertions (as A) or wrapping variables in intermediate objects, which obscures the original business logic.
  • Technical Debt: “Quick fixes” like any or as unknown as T bypass the type system, allowing actual bugs to leak into production.

Example or Code

enum UnionType { a, b }

type A = { type: UnionType.a; valueA: string };
type B = { type: UnionType.b; valueB: number };
type AB = A | B;

function processPair(x: T, y: T): void {
  if (x.type === UnionType.a) {
    // x is narrowed to A here
    console.log(x.valueA); 

    // ERROR: Property 'valueA' does not exist on type 'T'.
    // Even though x and y are both T, the compiler doesn't know T is A.
    console.log(y.valueA); 
  }
}

How Senior Engineers Fix It

A senior engineer avoids “lying” to the compiler with type assertions and instead uses Type Predicates or Redefinition of the Constraint.

  1. Type Guards: Use a custom type guard to prove to the compiler that the relationship holds.
  2. Constraint Refinement: Instead of using a generic T that is too broad, narrow the scope of the function or use a more specific union check.
  3. The “Object Wrapper” Pattern: While the user wanted to avoid it, wrapping values into a single object is often the most “honest” way to tell the compiler that the properties are tied to a single cohesive state.

The most robust fix for this specific generic problem is to use a Type Guard that validates the relationship:

function isTypeA(obj: T): obj is T & A {
  return obj.type === UnionType.a;
}

function processPairFixed(x: T, y: T): void {
  if (isTypeA(x) && isTypeA(y)) {
    // Now both are recognized as T & A
    console.log(x.valueA, y.valueA);
  }
}

Why Juniors Miss It

  • Reliance on Intuition: Juniors often think, “If x is A, and x and y are the same type, then y must be A.” They confuse human logic with formal type theory.
  • The any Trap: When the compiler throws an error, a junior’s first instinct is often to use (y as any).valueA to make the error go away, rather than questioning why the type system is disagreeing with them.
  • Ignoring the “Why”: They see the error as a nuisance rather than a signal that their abstraction (the generic constraint) is too weak for the logic they are trying to implement.

Leave a Comment