Skip to content

Null unions: equality, truth, text, arrays, and loose null == undefined - #424

Merged
ASDAlexander77 merged 6 commits into
mainfrom
fix-null-union-gaps
Oct 1, 2026
Merged

ASDAlexander77 merged 6 commits into
mainfrom
fix-null-union-gaps

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Summary

Fixes the five gaps listed at the end of #422, plus two narrowing bugs found along the way. One commit per fix; each test was seen failing first.

# Bug Before
A n: number | null: n === 2, print(n), if (n) compile errors (can't cast from 'null' to 's32' / 'string' / 'boolean')
B p: number[] | null: any read of the array cast from array type ... is not supported
C p: { k: number } | null: if (p) p.k Cast from {k:number} | null to boolean is not supported
D v: C | number: v === 3 invalid cast from C to s32
E an optional holding null: s == undefined false (JS: true)

Changes

  • E: loosely, an optional holding null equals undefined (OptionalLogicHelper). For == and !=, when the element type can hold null, a held value is compared with null as well. It is read only when there is one.
  • B: the array is read out of a number[] | null (CastLogicHelper). This union has no tag; it is the array struct itself. The lowering's cast to an array went to castToArrayType before unwrapping the union to its base type, as it does for every other target type.
  • A (cast) / C: a value type or null is tested for truth, narrowed and read as text (castFromNullableUnion, checkSafeCastBoolean):
    • A tagged union of one member and null is false when it holds null, and its text is "null".
    • if (x) now narrows such a union the way x !== null does. Only the truthy branch is narrowed: the other may hold 0 or "" as well as null.
    • An object literal whose field is s32 now goes into { k: number } | null, because it is cast to the one member.
    • n! reads the member.
  • A (equality) / D: a union equals a value when the member it holds does (binaryOpLogicForUnionAndValue). When a union has a member that can't be compared with the value (null, or a class against a number), a generated helper compares the members that can, by typeof. The others count as not equal. A union whose members can all be compared keeps the existing cast path, so number | string === 2 is unchanged.

Found and fixed on the way

  • Narrowing from a tagged union to an untagged one read garbage. With strict null checks, string | number | null narrowed by typeof u == "number" leaves string | null, which is the string's own pointer, not a tagged struct. addSafeCastStatement cast between them as if they had the same layout (invalid cast from '!llvm.struct<...>' to '!llvm.ptr'). The value is now read out of the tagged union. This bug predates the branch and affects user code:
    function f(u: string | number | null) { if (typeof u == "number") return 1; return u === "a"; }
  • A value narrowed to null is null, also under --di. The else of if (p !== null) on a number[] | null read null out of the union: the array struct cast to a pointer. Only --di kept it alive, and it failed with LLVM Translation failed for operation: builtin.unrealized_conversion_cast. It also predates the branch.

Behaviour changes

  • union === value with a member that can't be compared with the value is now false (or true for !==) at run time. It used to be a compile error.
  • let x: number = n and n > 1 with n: number | null are still compile errors, as in TypeScript. n! now compiles.

Tests

  • New: 00optional_loose_null.ts, 00union_nullable_array.ts, 00union_nullable_value.ts, 00union_value_equality.ts, each registered for compile and JIT.
  • Changed: the lowering-error test's trigger moved a third time. number[] | null reads now compile, so it widens a number[] to an any[] parameter instead.
  • Release: ctest -C Release 3331/3331.
  • Debug (full suite, with --di): 3293/3331. The same 38 tests fail on main (812db3c2) in Debug, so none come from this branch. They fall into these groups:
    • throw/EH with f32 and bigint;
    • try-in-catch-with-finally;
    • the DialectInlinerInterface … promised by dialect 'llvm' but never implemented fatal error in the JIT with --opt;
    • test-bindgen-cli and debug-info-opt hanging on an assert dialog.

Found, not fixed

  1. 1 !== true and "a" !== 1 print false. isDefinitelyMismatchedForStrictEquals sets left = (op == !==), right = true, and then runs the op, which computes true !== true.
  2. function f(p: string | null) { if (p !== null) return p.length; return -1; } returns 4294967295 for null. The return-type merge picks an unsigned width over the -1 literal.

🤖 Generated with Claude Code

ASDAlexander77 and others added 6 commits September 30, 2026 23:51
`let s: string | null | undefined = null; s == undefined` was false. Comparing an optional with
undefined asked only whether it holds a value, and a value it holds may be null. For `==` and `!=`,
when the element type can hold null (a `T | null`, or a nullable type without strict null checks),
the value is compared with null too - read only when there is one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`number[] | null` has no tag - it is the array itself, null when it holds null - but the lowering's
cast to an array went to castToArrayType before the union was unwrapped to its base type, as it is for
every other target type, and failed: "cast from array type ... to array type ... is not supported".

The lowering-error test raised its error that way; it now widens a number[] to an any[] instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`number | null` and `{ k: number } | null` are unions with a tag, one member and null.
- Cast from one, castFromUnion turned every member into the target type, and null is not most types:
  `if (n)` was "can't cast from 'null' to 'boolean'", `print(n)` the same for string, and an object
  type was not known at all. castFromNullableUnion: tested for truth it is false when it holds null,
  as text "null"; read as anything else it is its member, unchecked, as `p!` is.
- `if (x)` narrowed an optional only. On such a union it narrows as `x !== null` does - the then branch
  of `if (x)`, the else of `if (!x)`; the other branch may hold 0 or "" as well as null.
- An object literal `{ k: 5 }` (an s32 field) did not go into `{ k: number } | null`: no member took it
  as it was. With one member and null, it is cast to that member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `let n: number | null`, `n === 2` did not compile ("can't cast from 'null' to 's32'"), nor did
  `C | number === 3` ("invalid cast from C to s32"). syncUnionTypes casts the union to the value's type,
  every member of it, and null, or a class against a number, is not that type. Now a union with such a
  member is compared by a generated helper: the members that can be compared with the value are, by
  typeof, and the others are not equal to it. A union whose members all can be compared
  (`number | string === 2`) keeps the cast.
- Narrowed by typeof, `string | number | null` is left `string | null`, which with strict null checks is
  the string's own pointer, not a tagged struct; addSafeCastStatement cast one to the other as if they
  had the same layout ("invalid cast from '!llvm.struct<...>' to '!llvm.ptr'") and read garbage. The
  value is read out of the tagged union - a null it holds is stored as a null pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`if (p !== null) ... else ...` with `p: number[] | null` narrowed p in the else branch by reading null
out of the union - which has no tag, so that was the array struct cast to a pointer. Nothing used it,
unless --di gave it a debug record: "LLVM Translation failed for operation:
builtin.unrealized_conversion_cast". Narrowed to null, the value is a null.

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

castFromNullableUnion read the member of `number | null` for any target type, so
`let x: number = n` and `n > 1` - refused by TypeScript, and a compile error here before - compiled
to an unchecked read. It now handles only truth and text; the member is read by a narrowing, or by
`n!`, which returned its operand unchanged and so was refused too: it now reads the member of a
union with a tag, one member and null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit e6d460d into main Oct 1, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-null-union-gaps branch October 1, 2026 00:02
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