Skip to content

Unsound: equality with an undefined-typed variable narrows void to never #1041

Open

Description

@cristianoc

Unsound: equality with an undefined-typed variable narrows void to never

Minimal reproduction

function g(): void {}

function f(y: undefined): void {
  const x = g();
  if (x === y) {
    const n: never = x;
    console.log("branch taken at type never:", n);
  }
}

f(undefined);

Save this as repro.ts and run:

make check-file-in-docker FILE=repro.ts

Actual behavior

SKJS accepts the program with no errors. It emits only the
@typescript-eslint/no-confusing-void-expression warning on the comparison.
At runtime both operands contain undefined, so the branch is taken and the
program prints:

branch taken at type never: undefined

Expected behavior

SKJS must not narrow x to never in a branch that can be taken.
tsc --strict --noEmit repro.ts rejects the never assignment because it does
not narrow the void value away.

Cause

evalVarEquality in src/TypeCheckRefine.sk special-cases the literal
undefined and jointly filters the internal TVoid and TUndefined units.
When the right operand is an undefined-typed variable, the generic path
instead computes inter(localType, otherType). General intersection treats
those units as distinct, so inter(void, undefined) becomes never even
though their runtime values can be strictly equal.

The warning for observing a void expression does not prevent narrowing. That
is reasonable: observing it is valid JavaScript and TypeScript permits the
comparison.

Relationship to existing void issues

#871 tracks the opposite assignability problem: SKJS rejects undefined where
void is expected. The closed design discussion #75 also covers the runtime
meaning of void. Neither reports this equality-narrowing path or its
reachable never result. No equivalent test, issue, or PR was found.

Suggested fix

For equality narrowing, account for the fact that void and undefined can
contain the same runtime value. When either operand has one of those unit
types, keep or remove both units together, as the existing literal-undefined
path already does. This closes the bug independently of whether #871 changes
general assignability or uses a narrower special case.

Tested against main at bafdcf7c62f921b3064a358d95907a0ea5703037.

Activity

  1. added
    bugSomething isn't working
    soundnessType-system soundness hole (accepts a program that goes wrong at runtime)
    on Aug 12, 2026
  2. mbouaziz commented on Nov 4, 2026

    @mbouaziz
    Contributor

    Fixed by #1059 (stacked on #1058): void & undefined is now undefined, not never — STypes::interWith crosses a void unit with an undefined unit on the other side (their tags differ, so the tag-keyed pairing never met them), and SType::overlaps reports them as overlapping. In if (x === y) with x: void, y: undefined, the true branch now narrows x to undefined, so const n: never = x is rejected. Pinned in tests/expect-error/void.ts.

    — Mistral Vibe

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsoundnessType-system soundness hole (accepts a program that goes wrong at runtime)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions