Skip to content

An optional number compares as the wider type, and only when it holds a value - #423

Merged
ASDAlexander77 merged 1 commit into
fix-null-undefined-unionsfrom
fix-optional-number-compare
Sep 30, 2026
Merged

ASDAlexander77 merged 1 commit into
fix-null-undefined-unionsfrom
fix-optional-number-compare

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Summary

Stacked on #422 (base fix-null-undefined-unions). It relies on that PR's check in OptionalLogicHelper that the optional holds a value. Merge #422 first, then retarget this PR to main.

let n: number | undefined = 0.5;
n === 0;          // was true
n = undefined;
n === 0;          // was true
n < 1;            // was true

Cause

adjustTypesForBinaryOp syncs numeric operands by walking the promotion order (number, f64, …, si32, …). It picks the first type that one of the operands has exactly. An optional is never exactly one of them, so the other operand's type won:

  • Against an integer literal: the type was si32. The optional was cast out to it, and 0.5 became 0.
  • Against a number variable: the type was number, which is right. But casting out of the optional read an empty one as 0 (ts.If / ts.Default), so undefined === zero was true.

Change

syncOptionalNumericTypes runs for comparisons with exactly one optional numeric side:

- and * share that case, and are left as they were.

Tests

  • New: 00optional_number_compare.ts (compile and JIT). It covers a fraction against integer literals and number variables, an empty optional against both, s32 | undefined, and the literal on either side. It was seen failing first (assertion failed: 0.5 === 0).
  • Release: ctest -C Release 3323/3323.
  • Debug: the 77 related tests pass.

🤖 Generated with Claude Code

… a value

`let n: number | undefined = 0.5; n === 0` was true. The numeric sync in adjustTypesForBinaryOp looked
for the first promotion type either operand had exactly; an optional never is one, so the other
operand's type won - an integer literal's si32 - and the optional was cast out to it, 0.5 becoming 0.
The same cast read an empty optional as 0: `undefined === 0`, `undefined < 1` were true.

A comparison with one optional numeric side now widens both to the wider of the optional's element
type and the other operand's type, and keeps the optional, so the lowering compares only when it holds
a value (OptionalLogicHelper). `-` and `*`, which share the case, are left as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit bf7c1be into fix-null-undefined-unions Sep 30, 2026
@ASDAlexander77
ASDAlexander77 deleted the fix-optional-number-compare branch September 30, 2026 22:23
ASDAlexander77 added a commit that referenced this pull request Sep 30, 2026
…value (#422)

* `T | null | undefined` accepts null, and an empty optional equals no value

- Strict null checks: `string | null | undefined` (and a class) became optional<string> without the
  null the checker needs, so null was a compile error. The null member now goes in before undefined
  wraps the type: optional<union<string, null>>.
- Without strict null checks a nullable type still absorbs null, but a union of several types is a
  tagged value, not one pointer: `string | number | null` keeps null as a member (it was a verify
  error to store null in it).
- `number | null | undefined` holding null crashed the lowering on `=== null`: LogicOp read the union
  tag with ExtractValueOp from a ts.union value. An optional's unwrapped value arrives unconverted.
- An optional compared with a value was unwrapped without asking whether it holds one, so an empty
  `string | undefined` was `=== null` and an empty `boolean | undefined` `=== false`. The values are
  compared only when it holds one; loosely it still equals null.
- A value cast to `T | null` is cast to T first: an object literal to `Interface | null`, a const
  array to `number[] | null`, were left for the lowering, which cannot convert them.
- The `// @strict-null` pragma never reached the type helper's copy of the options, so unions were
  built strict in files compiled non-strict.

The lowering-error test used `number[] | null = [1, 2]` to raise its error, which now compiles; it
reads the array out of a `number[] | null` instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* An optional number compares as the wider type, and only when it holds a value (#423)

`let n: number | undefined = 0.5; n === 0` was true. The numeric sync in adjustTypesForBinaryOp looked
for the first promotion type either operand had exactly; an optional never is one, so the other
operand's type won - an integer literal's si32 - and the optional was cast out to it, 0.5 becoming 0.
The same cast read an empty optional as 0: `undefined === 0`, `undefined < 1` were true.

A comparison with one optional numeric side now widens both to the wider of the optional's element
type and the other operand's type, and keeps the optional, so the lowering compares only when it holds
a value (OptionalLogicHelper). `-` and `*`, which share the case, are left as they were.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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