Skip to content

fix(types): reject symbol operands across arithmetic/comparison/unary operators#1261

Open
nickna wants to merge 5 commits into
mainfrom
worktree-epic-99
Open

fix(types): reject symbol operands across arithmetic/comparison/unary operators#1261
nickna wants to merge 5 commits into
mainfrom
worktree-epic-99

Conversation

@nickna

@nickna nickna commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

Continuation of epic #99 / #80 Track-1 (TS conformance), building on the now-merged #1259/#1260.

Symbol operand rejection (arithmetic/comparison/unary/compound-assign)

Root cause: Symbol.for(key) was typed as Any instead of symbol. Symbol() (bare call) was already special-cased to return TypeInfo.Symbol, but Symbol.for's callee shape (Expr.Get on the Symbol global, not a bare Expr.Variable call) didn't match that special case, so it fell through to evaluating the bare Symbol identifier (which resolves to Any for unrelated reasons) and calling .for on Any stayed Any. Every arithmetic/comparison/unary operand check downstream silently no-oped as a result. Added Symbol.for/Symbol.keyFor alongside the existing Symbol() case.

With Symbol.for correctly typed, the operator checks themselves turned out to be missing or using the wrong diagnostic code for symbol operands specifically, across nearly every operator category (arithmetic/bitwise/shift TS2362/2363-split, comparison/unary TS2469, nuanced binary +/+=). Also fixes prefix/postfix increment/decrement's line attribution and widens them to accept bigint.

Result: flips symbolType4/5/6/7/8/10/12 (7 tests) from Fail to Pass.

Optional-parameter body-widening + TS2721/2722/2723 + loose-equality narrowing

Investigating logicalAssignment5 (needs TS2722 "cannot invoke possibly-undefined") surfaced a much bigger, foundational gap: a bare ?-optional parameter (function f(x?: T)) was bound inside the function body using its raw declared type T, never T | undefined, even though the caller can genuinely omit it — function f(x?: string) { x.toUpperCase() } produced zero diagnostics, while the explicit-union equivalent correctly errored. Fixed via a new WidenOptionalParamsForBody helper applied at every body-binding site (functions, arrows/function expressions/object method shorthand, class methods in both class-declaration and class-expression paths).

Also added the missing TS2721/2722/2723 ("cannot invoke an object which is possibly 'null'/'undefined'/'null' or 'undefined'") checks for non-optional calls on a nullable callee — previously silently allowed.

Widening optional parameters exposed a 285-test xUnit regression: our own bundled stdlib (stdlib/node/path.ts) uses the idiomatic ext != null && ext.length > 0 on an optional parameter, which never actually narrowed away undefined — loose equality (==/!=) against null/undefined wasn't recognized as a narrowing pattern at all (only strict ===/!== and truthiness were). This is a general, pre-existing gap unrelated to any specific type; it was simply never exercised until optional parameters became genuinely nullable. Fixed both AnalyzeNullCheck and AnalyzePathNullCheck to narrow away both null and undefined on a loose comparison against either literal (matching JS's null == undefined), and added the previously-entirely-missing undefined-literal patterns to AnalyzePathTypeGuard.

Left open: logicalAssignment5's comma-expression case (f ??= (f.toString(), (a=>a))) still doesn't fully match tsc — f.toString() throwing during RHS evaluation aborts the rest of the statement (including narrowing f afterward) before the recovery wrapper catches it. Fixing this needs a separate, deeper architectural change to let type inference continue past an internal throw within one statement; out of scope here.

Test plan

  • dotnet test (SharpTS.Tests) — 15452/15452 passing, 0 failures
  • SharpTS.Test262 interpreted + compiled baselines — 0 failures
  • SharpTS.TypeScriptConformance baseline — 216 Pass / 62 Fail / 0 ParseError, 0 regressions vs. committed baseline

nickna added 5 commits July 5, 2026 16:01
…rators

Root cause: Symbol.for(key) typed as Any instead of symbol. Symbol()
(bare call) was already special-cased to return TypeInfo.Symbol, but
Symbol.for's callee shape (Expr.Get on the Symbol global, not a bare
Expr.Variable call) didn't match that special case, so it fell through
to evaluating the bare `Symbol` identifier (which resolves to Any for
unrelated reasons) and calling `.for` on Any stayed Any. Every
arithmetic/comparison/unary operand check downstream silently no-oped
as a result, since Any bypasses all of them. Added Symbol.for/keyFor
alongside the existing Symbol() case.

With Symbol.for correctly typed, the operator checks themselves turned
out to be missing or using the wrong diagnostic code for symbol
operands specifically, across nearly every operator category:

- Arithmetic/bitwise/unsigned-shift (*, /, %, <<, >>, >>>, &, ^, |) and
  their compound-assign forms: tsc reports each invalid operand
  independently (TS2362 left, TS2363 right — both can fire on one
  expression). New shared RecordInvalidArithmeticOperand helper.
- Comparison (<, >, <=, >=): symbol gets its own single-diagnostic
  message (TS2469) instead of the generic two-type TS2365.
- Unary -/~: same TS2469 branch. Unary + had NO handling at all
  (silently passed any operand through unchanged) — added the missing
  branch, scoped to just the symbol rejection.
- Binary + and += (CheckPlusOperator/CheckCompoundAssign): most
  nuanced — tsc's message choice depends on the other operand type
  (symbol+symbol or symbol+number get the generic TS2365; symbol
  paired with string/any/a union merely containing symbol get the
  symbol-specific TS2469). New ContainsSymbolType helper (true for a
  union with *any* symbol constituent, unlike an all-constituents
  check) and IsBareNumberType (deliberately not Any-permissive, so
  `any` isn't mistaken for "the other side was a number").

Also fixes prefix/postfix increment/decrement's line attribution (no
explicit line was passed, so the diagnostic landed on whatever
statement `_currentStatementLine` happened to be instead of the
++/-- itself) and widens them to accept bigint per tsc's own message
wording.

Found and fixed a real regression along the way: an early version of
RecordInvalidArithmeticOperand only recorded diagnostics (never threw),
which silently broke exception-based single-shot checking (two
existing xUnit tests, and the default interpreter path in general) for
ANY invalid arithmetic operand, not just symbol. Fixed by recording the
diagnostic that won't be thrown directly, then throwing the other —
single-shot callers still see an exception, and recovery mode's
per-statement catch still ends up with both diagnostics.

Removed IsSymbolType as dead code once superseded by ContainsSymbolType
everywhere; its Any-permissive construction had also caused a second bug
(any wrongly "matched" the symbol predicate).

Flips symbolType4/5/6/7/8/10/12 from Fail to Pass, 0 regressions.
…bodies

A bare `?`-optional parameter (`function f(x?: T)`) was bound inside the
function/method/arrow BODY using its raw declared annotation type `T`,
never `T | undefined` — even though the caller can genuinely omit it.
`function f(x?: string) { x.toUpperCase() }` produced zero diagnostics,
while the explicit-union equivalent `function f(x: string | undefined)`
correctly flagged it. The signature type shown externally (`f?: T`, used
for arity/display) was never wrong; only the in-body binding was.

Root cause: BuildFunctionSignature (TypeChecker.cs) resolves each
parameter's declared type once and reuses it both for the function's own
callable signature and for defining the parameter in the checking scope.
A defaulted parameter (`x: T = default`) is correctly NOT widened — the
default substitutes a same-typed value before the body ever observes it,
so it's genuinely never undefined there.

Fix: new WidenOptionalParamsForBody helper, applied at every body-binding
call site (top-level functions, arrow/function expressions and object
method shorthand, and class methods in both the class-declaration and
class-expression paths — a constructor is just a method named
"constructor" so it's covered by the same fix).
…22/2723)

A non-optional call (`f(42)`, not `f?.(42)`) on a callee whose type is a
nullable union previously never errored: CheckCall's union-of-function-
members branch explicitly added `undefined` to the result type instead
of rejecting the call, on the theory that "calling undefined would fail
at runtime, but for type checking we allow it" — which doesn't match
tsc, which always rejects this.

Adds the missing check right after the existing optional-call nullish-
stripping logic: a non-optional call on a union containing null and/or
undefined now throws TS2721 (null), TS2722 (undefined), or TS2723 (both),
matching tsc's exact diagnostic selection. The now-unreachable "allow
calling undefined" branch is removed as dead code — a non-optional call
on a nullable union throws before reaching it, and an optional call has
already stripped null/undefined by that point.
`x == null` / `x != null` / `x == undefined` / `x != undefined` (loose
equality) never narrowed at all — only strict `===`/`!==` and truthiness
did. This is a long-standing, general narrowing gap, unrelated to any
specific type; it was simply never exercised by an existing test because
nothing produced a genuinely-nullable value through this exact idiom
until the optional-parameter body-widening fix started doing so —
stdlib/node/path.ts's `ext != null && ext.length > 0` (ext is a bare
optional parameter) newly failed to type-check once `ext` correctly
became `string | undefined` in the body, surfacing this pre-existing gap
as a 285-test xUnit regression.

Root cause: AnalyzeNullCheck and AnalyzePathNullCheck's `checkingForNull`
flag only ever excluded ONE of null/undefined (whichever literal was
compared), with no way to express "exclude both" — the callers pass the
same flag regardless of whether the source comparison was loose or
strict. JS's abstract equality treats null and undefined as mutually
`==`-equal, so `x == null` must exclude BOTH from the union, unlike
`x === null` which excludes only null.

Both helpers gain a `checkBoth` parameter, threaded from the call sites
in AnalyzeTypeGuard and AnalyzePathTypeGuard based on whether the
operator is the loose (EQUAL_EQUAL/BANG_EQUAL) or strict
(EQUAL_EQUAL_EQUAL/BANG_EQUAL_EQUAL) variant. AnalyzePathTypeGuard was
also missing `undefined`-literal patterns entirely (only `null`-literal
comparisons were recognized for path/property narrowing) — added to
match the existing bare-variable patterns.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant