Repository navigation
Null unions: equality, truth, text, arrays, and loose null == undefined - #424
Merged
Merged
Conversation
`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>
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
n: number | null:n === 2,print(n),if (n)can't cast from 'null' to 's32'/'string'/'boolean')p: number[] | null: any read of the arraycast from array type ... is not supportedp: { k: number } | null:if (p) p.kCast from {k:number} | null to boolean is not supportedv: C | number:v === 3invalid cast from C to s32null:s == undefinedChanges
OptionalLogicHelper). For==and!=, when the element type can holdnull, a held value is compared withnullas well. It is read only when there is one.number[] | null(CastLogicHelper). This union has no tag; it is the array struct itself. The lowering's cast to an array went tocastToArrayTypebefore unwrapping the union to its base type, as it does for every other target type.castFromNullableUnion,checkSafeCastBoolean):nullis false when it holdsnull, and its text is"null".if (x)now narrows such a union the wayx !== nulldoes. Only the truthy branch is narrowed: the other may hold0or""as well asnull.s32now goes into{ k: number } | null, because it is cast to the one member.n!reads the member.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, bytypeof. The others count as not equal. A union whose members can all be compared keeps the existing cast path, sonumber | string === 2is unchanged.Found and fixed on the way
string | number | nullnarrowed bytypeof u == "number"leavesstring | null, which is the string's own pointer, not a tagged struct.addSafeCastStatementcast 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:nullisnull, also under--di. Theelseofif (p !== null)on anumber[] | nullreadnullout of the union: the array struct cast to a pointer. Only--dikept it alive, and it failed withLLVM Translation failed for operation: builtin.unrealized_conversion_cast. It also predates the branch.Behaviour changes
union === valuewith a member that can't be compared with the value is nowfalse(ortruefor!==) at run time. It used to be a compile error.let x: number = nandn > 1withn: number | nullare still compile errors, as in TypeScript.n!now compiles.Tests
00optional_loose_null.ts,00union_nullable_array.ts,00union_nullable_value.ts,00union_value_equality.ts, each registered for compile and JIT.number[] | nullreads now compile, so it widens anumber[]to anany[]parameter instead.ctest -C Release3331/3331.--di): 3293/3331. The same 38 tests fail onmain(812db3c2) in Debug, so none come from this branch. They fall into these groups:f32andbigint;try-in-catch-with-finally;DialectInlinerInterface … promised by dialect 'llvm' but never implementedfatal error in the JIT with--opt;test-bindgen-clianddebug-info-opthanging on an assert dialog.Found, not fixed
1 !== trueand"a" !== 1print false.isDefinitelyMismatchedForStrictEqualssetsleft = (op == !==),right = true, and then runs the op, which computestrue !== true.function f(p: string | null) { if (p !== null) return p.length; return -1; }returns4294967295fornull. The return-type merge picks an unsigned width over the-1literal.🤖 Generated with Claude Code