Skip to content

T | null | undefined accepts null, and an empty optional equals no value - #422

Merged
ASDAlexander77 merged 2 commits into
mainfrom
fix-null-undefined-unions
Sep 30, 2026
Merged

ASDAlexander77 merged 2 commits into
mainfrom
fix-null-undefined-unions

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Summary

T | null | undefined now accepts null, and an optional that holds undefined no longer compares equal to null, false, "" or 0-like values of its own type. Three bugs were reported; fixing them needed three more.

The three bugs

1. Strict null checks (the default): string | null | undefined rejected null.

let s: string | null | undefined = null;
// error: can't cast from 'null' to 'string | undefined' in 'strict null mode'

string | null is union<string, null>, the null being there only for the checker. With | undefined, the single-type branch of getUnionTypeMergeTypes returned optional<string> before that null was added. The null now goes in first: optional<union<string, null>>. Classes had the same bug.

2. Without strict null checks: string | number | null couldn't store null.

error: 'ts.Cast' op type ['!ts.null'] can't be stored in ['!ts.union<!ts.number, !ts.string>']

Next to a nullable type (string, a class), null is dropped, because the pointer holds it. A union of several types is a tagged value, not one pointer, so null now stays as a member when more than one type is left after merging.

3. number | null | undefined holding null crashed the lowering on === null.

error: 'llvm.extractvalue' op operand #0 must be LLVM aggregate type, but got '!ts.union<!ts.null, !ts.number>'

LogicOp reads the union tag with ExtractValueOp. LogicalBinaryOpLowering passes it converted operands, but an optional's unwrapped value (from OptionalLogicHelper) is still a ts.union. The value is converted to the LLVM struct first when it isn't one already.

Three more fixes

  • An optional compared with a value is compared only when it holds one. OptionalLogicHelper unwrapped it without asking. The TODO said so: "rewrite code to take in account that Opt value can be undefined". So an empty string | undefined was === null, and an empty boolean | undefined was === false. The new tests fail without this. Loosely, an empty optional still equals null (01arguments.ts relies on it).
  • The // @strict-null pragma now reaches the union builder. MLIRTypeHelper keeps a copy of CompileOptions, and MLIRGen's mth took it before the pragma was read. Files marked @strict-null false got non-strict casts but strict unions. This changes union layout in every existing @strict-null false test. The suite passes.
  • A value cast to T | null is cast to T first (castToNullableUnionType). An object literal passed as Interface | null, or a const array as number[] | null, went straight to the lowering, which can't convert them. Fixing bug 1 routed Maybe<Path> (null | undefined | Path) onto this path, and path.ts failed.

Tests

  • New: 00union_null_undefined.ts (strict) and 00union_null_undefined_nonstrict.ts, each registered for compile and JIT. Each fix was seen failing first.
  • Changed: lowering-errors/array_literal_element_type.ts raised its error with number[] | null = [1, 2], which now compiles. It now reads .length from a number[] | null (see below).
  • Release: ctest -C Release 3321/3321.
  • Debug: the 75 related tests (new tests, lowering-error, optional, 01arguments, path) pass.

Found, not fixed here

  1. let n: number | undefined = 0.5; n === 0 is true. MLIRGen's numeric sync casts the optional to the literal's type (si32), and an empty one becomes 0, so undefined === 0 and undefined < 1 are true too. Next PR.
  2. number | null can't be compared with 2 or printed (can't cast from 'null' to 's32', from ___cast).
  3. Reading a number[] | null fails in the lowering (cast from array type ... is not supported).
  4. { k: number } | null can't be tested with if (p).
  5. C | number === 3 fails with invalid cast from C to s32.
  6. Loose null == undefined is false when an optional holds null (whenOneValueIsUndef).

🤖 Generated with Claude Code

…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>
… 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>
@ASDAlexander77
ASDAlexander77 merged commit 812db3c into main Sep 30, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-null-undefined-unions branch September 30, 2026 22:41
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