Skip to content

A union of pointer types stores null in its pointer, not as a member - #547

Merged
ASDAlexander77 merged 4 commits into
mainfrom
union-null-absorbed-by-pointers
Oct 11, 2026
Merged

ASDAlexander77 merged 4 commits into
mainfrom
union-null-absorbed-by-pointers

Conversation

@ASDAlexander77

@ASDAlexander77 ASDAlexander77 commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

The rule

A class or a string holds null in its own pointer. That's why Class | null is stored as just the class, with | null kept on the type only so that strict-null code can allow or refuse assigning null. This PR applies the same rule to unions of several such types:

Union Stored as Null is
Class | null the class (unchanged) a null pointer
A | B | null, string | A | null tagged A | B (was null | A | B) a null pointer in whichever member
boolean | A | null, string | number | null tagged, with its own null member (unchanged) the null member (0 can't stand for null)

The declared type keeps null under strict null checks, so assigning null is still allowed or refused as before.

What this fixes

A null A widened to A | B | null crashed === null, truth tests and ??: each went through a generated cast whose instanceof read the vtable through the null pointer. Printing an A | B | null (${u}) didn't compile in strict mode ("can't cast from 'null' to 'string'"). This also closes the gap #540 left for unions with several classes.

Changes

  • Type rule (MLIRTypeHelper): needsNullMember is false when every non-null member absorbs null (isNullAbsorbingType: class, string, array, object, opaque). The merged path of getUnionTypeMergeTypes no longer adds null to the storage type (disableStrickNullCheck) in strict mode; the non-merged path already worked that way. The storage layout doesn't change, because null and the absorbing members are all pointer-sized.
  • === null / != null: syncUnionTypes no longer casts a tagged union to null through a generated cast before comparing. LogicOp compares the union directly: by its tag when the actual type (from isUnionTypeNeedsTag) has a null member, otherwise by the first word of its value field.
  • Generated union cast (castFromUnion): for such a union it first asks if (t === null), so no member test meets a null instance. The result is false for a truth test, "null" for text, null where the target accepts null, and otherwise the same throw the function ends with. A union of null and several types can be tested for truth #540's one-class special case is removed; between A null class, string or array widened to a union becomes its null member #546 and this check, a class member never sees a null pointer.
  • CastLogicHelper (A null class, string or array widened to a union becomes its null member #546) uses the shared MLIRTypeHelper::isNullAbsorbingType.

Unchanged: typeof of a null held this way says class, which is what it already says for a Class | null holding null.

Testing

  • New 00union_null_in_pointer.ts (compile + JIT + rc/none corpus): a widened null and a literal null in A | B | null (=== null, != null, truth test, ??, narrowing by !== null, as text), non-null values, and string | A | null (null, empty string, text, class). It passes under gc, rc and none. On current main it fails to compile (can't cast from 'null' to 'string').
  • Full ctest: 3976/3976 pass. That includes typeGuardFunction.ts, which caught a first version of the null check that returned null to a B | C | A target.

🤖 Generated with Claude Code

ASDAlexander77 and others added 4 commits October 11, 2026 14:31
Cast into a tagged union, a value got its static type as the tag. A
class, string, array or object value holding null was therefore stored
as that type holding a null pointer: a null `Handle | null` widened to
`boolean | Handle | null` was tagged "class", so `=== null` missed it,
`typeof` said "class", and a truth test read its vtable through the
null pointer. Where the union has a null member of its own, such a value
now takes the null tag when its pointer is null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A class or a string holds null in its own pointer, which is why
`Class | null` is stored as the class. A union of several such types
(`A | B | null`, `string | A | null`) still got a null member of its own;
now it doesn't. Its value is null when its pointer is null, whichever
member's tag it carries. The declared type keeps `| null` under strict
null checks, which is what allows or refuses assigning null. A union
with a member that can't hold null (a number: 0 is not null) keeps its
null member.

- needsNullMember is false when every non-null member absorbs null, and
  the storage type (disableStrickNullCheck) no longer adds null in strict
  mode on the merged path either.
- `=== null` on a tagged union is no longer cast to null through a
  generated cast first (syncUnionTypes): LogicOp compares the union, by
  its tag or, for such a union, by its value's pointer - judged from the
  actual type isUnionTypeNeedsTag returns.
- The generated union cast asks `t === null` first for such a union, so
  no `instanceof` meets a null instance: false for a truth test, "null"
  as text, null where the target takes it, else the usual throw. #540's
  one-class special case is gone.
- CastLogicHelper uses MLIRTypeHelper::isNullAbsorbingType.

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

# Conflicts:
#	tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h
#	tslang/test/tester/CMakeLists.txt
…it back

MLIRTypeHelper::isNullAbsorbingType replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit cdd252f into main Oct 11, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the union-null-absorbed-by-pointers branch October 11, 2026 14:18
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