From 5bb6ce6ba063288def99897579cb95826a48acf0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 23:51:49 +0100 Subject: [PATCH 1/6] Loosely, an optional holding null equals undefined `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 --- .../LowerToLLVM/OptionalLogicHelper.h | 65 +++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 2 + .../tester/tests/00optional_loose_null.ts | 46 +++++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 tslang/test/tester/tests/00optional_loose_null.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h b/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h index 6df44a906..ccb944a3c 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h @@ -53,19 +53,72 @@ class OptionalLogicHelper return WhenBothOptValues(opCmpCode); } - if (isa(rightType)) + if (isa(rightType) || isa(leftType)) { + auto optValue = isa(rightType) ? left : right; + auto undefValue = isa(rightType) ? right : left; + if (isLooseEquality(opCmpCode) && canHoldNull(mlir::cast(optValue.getType()).getElementType())) + { + return whenLooseUndefAgainstNullable(opCmpCode, optValue); + } + // when we have undef in 1 of values we do not condition to test actual values - return whenOneValueIsUndef(opCmpCode, left, right); + return whenOneValueIsUndef(opCmpCode, optValue, undefValue); } - if (isa(leftType)) + return whenOneOptValue(opCmpCode); + } + + static bool isLooseEquality(SyntaxKind opCmpCode) + { + return opCmpCode == SyntaxKind::EqualsEqualsToken || opCmpCode == SyntaxKind::ExclamationEqualsToken; + } + + // a `T | null` (strict null checks), or a type whose own pointer holds null (without them) + static bool canHoldNull(mlir::Type type) + { + if (auto unionType = dyn_cast(type)) { - // when we have undef in 1 of values we do not condition to test actual values - return whenOneValueIsUndef(opCmpCode, right, left); + return llvm::any_of(unionType.getTypes(), [](mlir::Type member) { return isa(member); }); } - return whenOneOptValue(opCmpCode); + return MLIRTypeCore::isNullableTypeNoUnion(type); + } + + // Loosely, null equals undefined: an optional holding null is `== undefined` as well, so it + // is not enough to ask whether it holds a value - a value it holds may be null. The value is + // read only when there is one. + template + mlir::Value whenLooseUndefAgainstNullable(SyntaxKind opCmpCode, mlir::Value optValue) + { + auto loc = binOp->getLoc(); + + TypeHelper th(rewriter); + CodeLogicHelper clh(binOp, rewriter); + + auto llvmBoolType = typeConverter.convertType(th.getBooleanType()); + auto optType = mlir::cast(optValue.getType()); + + auto hasValueBool = rewriter.create(loc, th.getBooleanType(), optValue); + auto hasValue = rewriter.create(loc, llvmBoolType, hasValueBool); + + return clh.conditionalExpressionLowering( + loc, llvmBoolType, hasValue, + [&](OpBuilder &builder, Location loc) { + mlir::Value value = rewriter.create(loc, optType.getElementType(), optValue); + mlir::Value nullValue = rewriter.create(loc, mlir_ts::NullType::get(rewriter.getContext())); + mlir::Value result = LogicOp( + binOp, opCmpCode, value, value.getType(), nullValue, nullValue.getType(), rewriter, typeConverter, compileOptions); + if (result && result.getType() != llvmBoolType) + { + result = rewriter.create(loc, llvmBoolType, result); + } + + return result; + }, + [&](OpBuilder &builder, Location loc) { + return clh.createI1ConstantOf(opCmpCode == SyntaxKind::EqualsEqualsToken); + }); } // One side is optional, the other a value. An optional holding undefined equals no value - not diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index e6d8c70b0..1c0402932 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -386,6 +386,7 @@ tslang_add_test(NAME test-compile-00-union-null-to-string COMMAND test-runner "$ tslang_add_test(NAME test-compile-00-union-null-undefined COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_undefined.ts") tslang_add_test(NAME test-compile-00-union-null-undefined-nonstrict COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_undefined_nonstrict.ts") tslang_add_test(NAME test-compile-00-optional-number-compare COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_number_compare.ts") +tslang_add_test(NAME test-compile-00-optional-loose-null COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") tslang_add_test(NAME test-compile-00-intersection-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-compile-00-intersection-type-generic COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") tslang_add_test(NAME test-compile-00-length COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00length.ts") @@ -855,6 +856,7 @@ tslang_add_test(NAME test-jit-00-union-null-to-string COMMAND test-runner -jit " tslang_add_test(NAME test-jit-00-union-null-undefined COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_undefined.ts") tslang_add_test(NAME test-jit-00-union-null-undefined-nonstrict COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_undefined_nonstrict.ts") tslang_add_test(NAME test-jit-00-optional-number-compare COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_number_compare.ts") +tslang_add_test(NAME test-jit-00-optional-loose-null COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") tslang_add_test(NAME test-jit-00-union-global-init COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_global_init.ts") tslang_add_test(NAME test-jit-00-intersection-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-jit-00-intersection-type-generic COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") diff --git a/tslang/test/tester/tests/00optional_loose_null.ts b/tslang/test/tester/tests/00optional_loose_null.ts new file mode 100644 index 000000000..18ae149e7 --- /dev/null +++ b/tslang/test/tester/tests/00optional_loose_null.ts @@ -0,0 +1,46 @@ +// Loosely, null and undefined are equal: an optional that holds null is `== undefined` as well as +// `== null`, and one that holds undefined is both too +function nullableString() { + let s: string | null | undefined = null; + assert(s == undefined, "string null == undefined"); + assert(!(s != undefined), "string null != undefined"); + assert(s == null, "string null == null"); + assert(!(s === undefined), "string null === undefined"); + + s = undefined; + assert(s == undefined, "string undefined == undefined"); + assert(s == null, "string undefined == null"); + + s = "a"; + assert(!(s == undefined), "string value == undefined"); + assert(s != undefined, "string value != undefined"); +} + +function nullableNumber() { + let n: number | null | undefined = null; + assert(n == undefined, "number null == undefined"); + assert(!(n != undefined), "number null != undefined"); + assert(!(n === undefined), "number null === undefined"); + + n = 1; + assert(!(n == undefined), "number value == undefined"); +} + +class C { + x = 1; +} + +function nullableClass() { + let c: C | null | undefined = null; + assert(c == undefined, "class null == undefined"); + + c = new C(); + assert(!(c == undefined), "class value == undefined"); +} + +function main() { + nullableString(); + nullableNumber(); + nullableClass(); + print("done."); +} From 4beb281e041b8eb2577ce4552f77913f432fddbc Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 23:55:41 +0100 Subject: [PATCH 2/6] The array is read out of a `number[] | null` `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 --- .../TypeScript/LowerToLLVM/CastLogicHelper.h | 12 ++++++++ tslang/test/tester/CMakeLists.txt | 2 ++ .../array_literal_element_type.ts | 15 ++++++---- .../tester/tests/00union_nullable_array.ts | 28 +++++++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 tslang/test/tester/tests/00union_nullable_array.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h index fce51a20f..1349d4ac3 100644 --- a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h @@ -545,6 +545,18 @@ class CastLogicHelper if (auto arrType = dyn_cast(resType)) { + // `number[] | null` has no tag - it is the array itself, null when it holds null (the + // union below does the same for every other type, but an array never got that far) + if (auto inUnionType = dyn_cast(inType)) + { + MLIRTypeHelper mth(inUnionType.getContext(), compileOptions); + mlir::Type baseType; + if (!mth.isUnionTypeNeedsTag(loc, inUnionType, baseType)) + { + return cast(in, baseType, tch.convertType(baseType), resType, resLLVMType); + } + } + return castToArrayType(in, inType, resType); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 1c0402932..4c62e7ade 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -387,6 +387,7 @@ tslang_add_test(NAME test-compile-00-union-null-undefined COMMAND test-runner "$ tslang_add_test(NAME test-compile-00-union-null-undefined-nonstrict COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_undefined_nonstrict.ts") tslang_add_test(NAME test-compile-00-optional-number-compare COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_number_compare.ts") tslang_add_test(NAME test-compile-00-optional-loose-null COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") +tslang_add_test(NAME test-compile-00-union-nullable-array COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_array.ts") tslang_add_test(NAME test-compile-00-intersection-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-compile-00-intersection-type-generic COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") tslang_add_test(NAME test-compile-00-length COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00length.ts") @@ -857,6 +858,7 @@ tslang_add_test(NAME test-jit-00-union-null-undefined COMMAND test-runner -jit " tslang_add_test(NAME test-jit-00-union-null-undefined-nonstrict COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_undefined_nonstrict.ts") tslang_add_test(NAME test-jit-00-optional-number-compare COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_number_compare.ts") tslang_add_test(NAME test-jit-00-optional-loose-null COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") +tslang_add_test(NAME test-jit-00-union-nullable-array COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_array.ts") tslang_add_test(NAME test-jit-00-union-global-init COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_global_init.ts") tslang_add_test(NAME test-jit-00-intersection-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-jit-00-intersection-type-generic COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") diff --git a/tslang/test/tester/lowering-errors/array_literal_element_type.ts b/tslang/test/tester/lowering-errors/array_literal_element_type.ts index e6ae4f3c0..3097557eb 100644 --- a/tslang/test/tester/lowering-errors/array_literal_element_type.ts +++ b/tslang/test/tester/lowering-errors/array_literal_element_type.ts @@ -1,12 +1,15 @@ -// Lowering cannot read the array out of a number[] | null, and says so with an error. That error has -// to stop the compile: before it did, the object file was written and the program run. +// Lowering cannot widen a number[] to an any[] (it would need a per-element conversion), and says so +// with an error. That error has to stop the compile: before it did, the object file was written and +// the program run. // (The array-to-string cast this test used first hits llvm_unreachable in a Debug build before it -// reports anything. Its second, a const array literal stored as a number[] | null, compiles now.) -function lengthOf(p: number[] | null) { - return p ? p.length : -1; +// reports anything. Its next two, a const array literal stored as a number[] | null and the array +// read out of one, compile now.) +function count(p: any[]) { + return p.length; } function main() { - print(lengthOf(null)); + let a: number[] = [1, 2]; + print(count(a)); print("done."); } diff --git a/tslang/test/tester/tests/00union_nullable_array.ts b/tslang/test/tester/tests/00union_nullable_array.ts new file mode 100644 index 000000000..e38de45ff --- /dev/null +++ b/tslang/test/tester/tests/00union_nullable_array.ts @@ -0,0 +1,28 @@ +// `number[] | null` has no tag: it is the array itself, null when it holds null. Reading the array +// out of it, and storing null in it, are casts the lowering has to do between the two +function lengthOf(p: number[] | null) { + return p ? p.length : -1; +} + +function first(p: number[] | null) { + if (p !== null) { + return p[0]; + } + + return -1; +} + +function main() { + let a: number[] = [1, 2]; + assert(lengthOf(a) == 2, "length of an array"); + assert(lengthOf(null) == -1, "length of null"); + assert(lengthOf([3, 4, 5]) == 3, "length of an array literal"); + assert(first(a) == 1, "first of an array"); + assert(first(null) == -1, "first of null"); + + let b: number[] | null = null; + assert(b === null, "null stored"); + b = a; + assert(b !== null, "array stored"); + print("done."); +} From ba69859b75858e491afab6ba479827c764baa8c4 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 1 Oct 2026 00:01:53 +0100 Subject: [PATCH 3/6] A value type or null is tested for truth, narrowed and read as text `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 --- tslang/lib/TypeScript/MLIRGenCast.cpp | 83 +++++++++++++++++++ tslang/lib/TypeScript/MLIRGenImpl.h | 29 ++++++- tslang/test/tester/CMakeLists.txt | 2 + .../tester/tests/00union_nullable_value.ts | 52 ++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/tests/00union_nullable_value.ts diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index b44374677..f5f0704cd 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -1336,6 +1336,16 @@ namespace mlirgen return V(builder.create(location, type, value)); } } + + // one member and null (`{ k: number } | null`): what is not null is that member - + // an object literal `{ k: 5 }`, whose field is an s32, is converted to it + if (!isa(valueType) && types.size() == 2 + && llvm::any_of(types, [](mlir::Type subType) { return isa(subType); })) + { + auto memberType = isa(types[0]) ? types[1] : types[0]; + CAST(value, location, memberType, value, genContext); + return V(builder.create(location, type, value)); + } } else { @@ -1381,6 +1391,74 @@ namespace mlirgen return V(builder.create(location, type, value)); } + // `number | null`, `{ k: number } | null`: a value type or null is a union with a tag - one member + // and null. castFromUnion turns every member into the target type, and null is not most types + // ("can't cast from 'null' to 'boolean'"), nor does it know an object type at all. Tested for + // truth it is false when it holds null, as text it is "null"; read as anything else it is its + // member - what narrowing (`if (p) p.k`) reads - unchecked, as `p!` is. + std::optional MLIRGenImpl::castFromNullableUnion(mlir::Location location, mlir::Type type, mlir::Value value, mlir_ts::UnionType unionType, const GenContext &genContext) + { + if (isa(type) || isa(type) || isa(type) + || isa(type) || isa(type)) + { + return std::nullopt; + } + + mlir::Type memberType; + auto hasNull = false; + for (auto subType : unionType.getTypes()) + { + if (isa(subType)) + { + hasNull = true; + continue; + } + + if (memberType) + { + return std::nullopt; + } + + memberType = subType; + } + + if (!hasNull || !memberType) + { + return std::nullopt; + } + + auto readMember = [&]() { return V(builder.create(location, memberType, value)); }; + + if (!isa(type) && !isa(type)) + { + auto member = readMember(); + if (type == memberType) + { + return member; + } + + return cast(location, type, member, genContext); + } + + auto nullValue = builder.create(location, getNullType()); + auto isNull = builder.create( + location, getBooleanType(), builder.getI32IntegerAttr((int)SyntaxKind::EqualsEqualsEqualsToken), value, nullValue); + + MLIRCodeLogicHelper mclh(builder, location, compileOptions); + return mclh.conditionalValue(isNull, + [&]() -> ValueOrLogicalResult { + if (isa(type)) + { + return V(builder.create(location, getBooleanType(), builder.getBoolAttr(false))); + } + + return V(builder.create(location, getStringType(), builder.getStringAttr("null"))); + }, + [&](mlir::Type) -> ValueOrLogicalResult { + return cast(location, type, readMember(), genContext); + }); + } + std::optional MLIRGenImpl::castFromSourceSpecialCases(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext) { // union type to @@ -1391,6 +1469,11 @@ namespace mlirgen mlir::Type baseType; if (!toAny && mth.isUnionTypeNeedsTag(location, unionType, baseType)) { + if (auto result = castFromNullableUnion(location, type, value, unionType, genContext)) + { + return *result; + } + return castFromUnion(location, type, value, genContext); } diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 207b34e28..fe1f4c6a3 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -3629,8 +3629,34 @@ class MLIRGenImpl return addSafeCastStatement(exprVal, optType.getElementType(), inverse, elseSafeCase, genContext); } + // `number | null`, `{ k: number } | null`: a union with a tag and null. What is truthy is not + // null, as after `x !== null`; what is not may be null or a falsy value (0, ""), so only the + // branch where it is truthy is narrowed - the then branch of `if (x)`, the else of `if (!x)` + if (auto unionType = dyn_cast_or_null(exprEval)) + { + mlir::Type baseType; + if (mth.isUnionTypeNeedsTag(loc(exprVal), unionType, baseType) + && llvm::any_of(unionType.getTypes(), [](mlir::Type member) { return isa(member); })) + { + if (!inverse) + { + return addSafeCastStatement(exprVal, getNullType(), true, nullptr, genContext); + } + + if (elseSafeCase) + { + SmallVector members; + llvm::copy_if(unionType.getTypes(), std::back_inserter(members), + [](mlir::Type member) { return !isa(member); }); + elseSafeCase->expr = stripParenthesesAndUntangleEquals(exprVal); + elseSafeCase->safeType = getUnionType(members); + return mlir::success(); + } + } + } + return mlir::failure(); - } + } Expression stripParentheses(Expression exprVal) { @@ -11399,6 +11425,7 @@ class MLIRGenImpl std::optional castToTaggedUnionType(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext); std::optional castToNullableUnionType(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext); + std::optional castFromNullableUnion(mlir::Location location, mlir::Type type, mlir::Value value, mlir_ts::UnionType unionType, const GenContext &genContext); // union or optional or any or opaque source type std::optional castFromSourceSpecialCases(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 4c62e7ade..76419026e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -388,6 +388,7 @@ tslang_add_test(NAME test-compile-00-union-null-undefined-nonstrict COMMAND test tslang_add_test(NAME test-compile-00-optional-number-compare COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_number_compare.ts") tslang_add_test(NAME test-compile-00-optional-loose-null COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") tslang_add_test(NAME test-compile-00-union-nullable-array COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_array.ts") +tslang_add_test(NAME test-compile-00-union-nullable-value COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_value.ts") tslang_add_test(NAME test-compile-00-intersection-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-compile-00-intersection-type-generic COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") tslang_add_test(NAME test-compile-00-length COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00length.ts") @@ -859,6 +860,7 @@ tslang_add_test(NAME test-jit-00-union-null-undefined-nonstrict COMMAND test-run tslang_add_test(NAME test-jit-00-optional-number-compare COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_number_compare.ts") tslang_add_test(NAME test-jit-00-optional-loose-null COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") tslang_add_test(NAME test-jit-00-union-nullable-array COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_array.ts") +tslang_add_test(NAME test-jit-00-union-nullable-value COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_value.ts") tslang_add_test(NAME test-jit-00-union-global-init COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_global_init.ts") tslang_add_test(NAME test-jit-00-intersection-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-jit-00-intersection-type-generic COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") diff --git a/tslang/test/tester/tests/00union_nullable_value.ts b/tslang/test/tester/tests/00union_nullable_value.ts new file mode 100644 index 000000000..53f1fb240 --- /dev/null +++ b/tslang/test/tester/tests/00union_nullable_value.ts @@ -0,0 +1,52 @@ +// A value type (number, an object type) or null is a union with a tag. Tested for truth it is false +// when it holds null, else as its value is; read as its value it is that member; as text it is "null" +// or the value's text +function incrementOrMinus(n: number | null) { + if (n) { + return n + 1; + } + + return -1; +} + +function isSet(n: number | null) { + return n ? true : false; +} + +function keyOf(p: { k: number } | null) { + if (p) { + return p.k; + } + + return -1; +} + +function keyOrMinus(p: { k: number } | null) { + if (!p) { + return -1; + } + + return p.k; +} + +function main() { + assert(keyOrMinus({ k: 6 }) == 6, "object after early exit: value"); + assert(keyOrMinus(null) == -1, "object after early exit: null"); + assert(incrementOrMinus(2) == 3, "number: truthy value"); + assert(incrementOrMinus(null) == -1, "number: null"); + assert(incrementOrMinus(0) == -1, "number: zero is falsy"); + assert(isSet(5), "number: set"); + assert(!isSet(null), "number: not set"); + + assert(keyOf({ k: 5 }) == 5, "object: value"); + assert(keyOf(null) == -1, "object: null"); + + let n: number | null = 2; + print(n); + assert(`${n}` == "2", "number as text"); + n = null; + print(n); + assert(`${n}` == "null", "null as text"); + + print("done."); +} From f729f0b577a0b7d463336b491ee0c5d08d29d29c Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 1 Oct 2026 00:12:05 +0100 Subject: [PATCH 4/6] A union equals a value when the member it holds does - `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 --- tslang/lib/TypeScript/MLIRGenExpressions.cpp | 7 + tslang/lib/TypeScript/MLIRGenImpl.h | 192 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 2 + .../tester/tests/00union_value_equality.ts | 77 +++++++ 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 tslang/test/tester/tests/00union_value_equality.ts diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index 318127241..e84e1828d 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -831,6 +831,13 @@ namespace mlirgen return resultReturnUnions; } + auto resultReturnUnionAndValue = + binaryOpLogicForUnionAndValue(location, opCode, leftExpressionValue, rightExpressionValue, genContext); + if (resultReturnUnionAndValue.value || resultReturnUnionAndValue.failed()) + { + return resultReturnUnionAndValue; + } + auto leftExpressionValueBeforeCast = leftExpressionValue; auto rightExpressionValueBeforeCast = rightExpressionValue; diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index fe1f4c6a3..957b0d557 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -3414,13 +3414,29 @@ class MLIRGenImpl if (!inverse) { - if (isa(safeType)) + if (auto safeUnionType = dyn_cast(safeType)) { // no need to cast union type to as it will be // the same LLVMType structure //return mlir::failure(); // in case of union we just want to have the same structured type but with less types in union - castedValue = builder.create(location, safeType, exprValue); + // + // Unless what is left needs no tag: `string | number | null` narrowed to `string | null` + // (strict null checks) is the string's own pointer, not a tagged struct. Its value is + // read out of the union - a null it holds is stored as a null pointer. + mlir::Type safeBaseType; + mlir::Type exprBaseType; + if (!mth.isUnionTypeNeedsTag(location, safeUnionType, safeBaseType) + && mth.isUnionTypeNeedsTag(location, unionType, exprBaseType) + && safeBaseType) + { + auto baseValue = builder.create(location, safeBaseType, exprValue); + castedValue = builder.create(location, safeType, baseValue); + } + else + { + castedValue = builder.create(location, safeType, exprValue); + } } else { @@ -6531,6 +6547,178 @@ class MLIRGenImpl return mlir::success(); } + bool isPrimitiveForEquality(mlir::Type type) + { + return isPromotableNumeric(type) || isa(type) || isa(type) + || isa(type) || isa(type); + } + + bool isReferenceForEquality(mlir::Type type) + { + return isa(type) || isa(type) || isa(type) + || isa(type) || isa(type) || mth.isAnyFunctionType(type); + } + + // Whether a union's member can be compared with a value: two primitives can (a string with a + // number converts, as it did), two references when one casts to the other; null, undefined, a + // class against a number, never. + bool isComparableForEquality(mlir::Location location, mlir::Type memberType, mlir::Type valueType) + { + auto stripLiteral = [](mlir::Type type) { + auto literalType = dyn_cast(type); + return literalType ? literalType.getElementType() : type; + }; + + memberType = stripLiteral(memberType); + valueType = stripLiteral(valueType); + if (memberType == valueType) + { + return true; + } + + if (isPrimitiveForEquality(memberType) && isPrimitiveForEquality(valueType)) + { + return true; + } + + return isReferenceForEquality(memberType) && isReferenceForEquality(valueType) + && (mth.canCastFromTo(location, memberType, valueType) || mth.canCastFromTo(location, valueType, memberType)); + } + + // A union (with a tag) compared for equality with a value that is not a union: it equals the value + // when the member it holds does. syncUnionTypes casts the union to the value's type, which a + // member that cannot be compared with it does not survive - `number | null` read null as a + // number, and `C | number === 3` did not compile. When the union has such a member, a helper + // compares the members that can be compared, and the others are simply not equal. A union whose + // members all can be compared (`number | string === 2`) keeps the cast. + ValueOrLogicalResult binaryOpLogicForUnionAndValue(mlir::Location location, SyntaxKind opCode, mlir::Value leftExpressionValue, + mlir::Value rightExpressionValue, const GenContext &genContext) + { + if (opCode != SyntaxKind::EqualsEqualsEqualsToken && opCode != SyntaxKind::ExclamationEqualsEqualsToken + && opCode != SyntaxKind::EqualsEqualsToken && opCode != SyntaxKind::ExclamationEqualsToken) + { + return mlir::success(); + } + + if (!leftExpressionValue || !rightExpressionValue) + { + return mlir::success(); + } + + auto leftUnionType = dyn_cast(leftExpressionValue.getType()); + auto rightUnionType = dyn_cast(rightExpressionValue.getType()); + if (!leftUnionType == !rightUnionType) + { + return mlir::success(); + } + + auto unionType = leftUnionType ? leftUnionType : rightUnionType; + auto unionValue = leftUnionType ? leftExpressionValue : rightExpressionValue; + auto value = leftUnionType ? rightExpressionValue : leftExpressionValue; + auto valueType = value.getType(); + + mlir::Type baseType; + if (!mth.isUnionTypeNeedsTag(location, unionType, baseType)) + { + return mlir::success(); + } + + if (isa(valueType) || isa(valueType) || isa(valueType) + || isa(valueType)) + { + return mlir::success(); + } + + if (llvm::all_of(unionType.getTypes(), [&](mlir::Type member) { return isComparableForEquality(location, member, valueType); })) + { + return mlir::success(); + } + + if (auto literalType = dyn_cast(valueType)) + { + valueType = literalType.getElementType(); + CAST(value, location, valueType, value, genContext); + } + + // info, we add "_" extra as scanner append "_" in front of "__"; + auto funcName = "___union_eq_" + opName(opCode); + + // we need to remove current implementation as we have different implementation per union type + removeGenericFunctionMap(funcName); + + TypeOfOpHelper toh(builder); + + // a member it cannot be compared with is not equal to the value + auto notEqual = opCode == SyntaxKind::ExclamationEqualsEqualsToken || opCode == SyntaxKind::ExclamationEqualsToken; + + SmallVector classInstances; + stringstream ss; + ss << S("function __union_eq_") << stows(opName(opCode)) << S("(l: L, r: R) {\n"); + for (auto member : unionType.getTypes()) + { + if (!isComparableForEquality(location, member, valueType)) + { + continue; + } + + auto typeOfName = toh.typeOfAsString(member); + ss << S("if (typeof(l) == \"") << stows(typeOfName) << S("\") "); + if (typeOfName == "class") + { + ss << S("{ if (l instanceof TYPE_INST_ALIAS") << classInstances.size() << S(") return l ") + << Scanner::tokenStrings[opCode] << S(" r; }\n"); + classInstances.push_back(member); + } + else + { + ss << S("return l ") << Scanner::tokenStrings[opCode] << S(" r;\n"); + } + } + + ss << S("return ") << (notEqual ? S("true") : S("false")) << S(";\n}\n"); + + auto src = ss.str(); + + { + MLIRLocationGuard vgLoc(overwriteLoc); + overwriteLoc = location; + + if (mlir::failed(parsePartialStatements(src))) + { + assert(false); + return mlir::failure(); + } + } + + auto funcResult = resolveIdentifier(location, funcName, genContext); + + assert(funcResult); + + GenContext funcCallGenContext(genContext); + funcCallGenContext.typeAliasMap.insert({".TYPE_ALIAS_L", unionType}); + funcCallGenContext.typeAliasMap.insert({".TYPE_ALIAS_R", valueType}); + + for (auto [index, instanceOfType] : enumerate(classInstances)) + { + funcCallGenContext.typeAliasMap.insert({"TYPE_INST_ALIAS" + std::to_string(index), instanceOfType}); + } + + SmallVector operands; + operands.push_back(unionValue); + operands.push_back(value); + + NodeFactory nf(NodeFactoryFlags::None); + return mlirGenCallExpression( + location, + funcResult, + { + nf.createTypeReferenceNode(nf.createIdentifier(S(".TYPE_ALIAS_L")).as()), + nf.createTypeReferenceNode(nf.createIdentifier(S(".TYPE_ALIAS_R")).as()) + }, + operands, + funcCallGenContext); + } + mlir::LogicalResult instantiateGenericsForBinaryOp(mlir::Location location, mlir::Value &leftExpressionValue, mlir::Value &rightExpressionValue, const GenContext &genContext) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 76419026e..405c5de1a 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -389,6 +389,7 @@ tslang_add_test(NAME test-compile-00-optional-number-compare COMMAND test-runner tslang_add_test(NAME test-compile-00-optional-loose-null COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") tslang_add_test(NAME test-compile-00-union-nullable-array COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_array.ts") tslang_add_test(NAME test-compile-00-union-nullable-value COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_value.ts") +tslang_add_test(NAME test-compile-00-union-value-equality COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_value_equality.ts") tslang_add_test(NAME test-compile-00-intersection-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-compile-00-intersection-type-generic COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") tslang_add_test(NAME test-compile-00-length COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00length.ts") @@ -861,6 +862,7 @@ tslang_add_test(NAME test-jit-00-optional-number-compare COMMAND test-runner -ji tslang_add_test(NAME test-jit-00-optional-loose-null COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional_loose_null.ts") tslang_add_test(NAME test-jit-00-union-nullable-array COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_array.ts") tslang_add_test(NAME test-jit-00-union-nullable-value COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_nullable_value.ts") +tslang_add_test(NAME test-jit-00-union-value-equality COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_value_equality.ts") tslang_add_test(NAME test-jit-00-union-global-init COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_global_init.ts") tslang_add_test(NAME test-jit-00-intersection-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type.ts") tslang_add_test(NAME test-jit-00-intersection-type-generic COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00intersection_type_generic.ts") diff --git a/tslang/test/tester/tests/00union_value_equality.ts b/tslang/test/tester/tests/00union_value_equality.ts new file mode 100644 index 000000000..0606d8862 --- /dev/null +++ b/tslang/test/tester/tests/00union_value_equality.ts @@ -0,0 +1,77 @@ +// A union equals a value when the member it holds does: a member that cannot be compared with the +// value - null, a class against a number - is simply not equal to it. Cast to the value's type, the +// union read null as a number, a number as an s32 (2.5 === 2), and a class did not compile +class C { + x = 1; +} + +function nullableNumber() { + let n: number | null = 2.5; + assert(!(n === 2), "2.5 === 2"); + assert(n === 2.5, "2.5 === 2.5"); + assert(n !== 2, "2.5 !== 2"); + assert(2.5 === n, "2.5 === 2.5, union on the right"); + assert(n == 2.5, "2.5 == 2.5"); + + let zero: number = 0; + n = null; + assert(!(n === 0), "null === 0"); + assert(n !== 0, "null !== 0"); + assert(!(n == 0), "null == 0"); + assert(!(n === zero), "null === zero"); + assert(n === null, "null === null"); + + n = 0; + assert(n === 0, "0 === 0"); + assert(n === zero, "0 === zero"); +} + +function classOrNumber() { + let v: C | number = 3; + assert(v === 3, "3 === 3"); + assert(!(v === 4), "3 === 4"); + assert(3 === v, "3 === 3, union on the right"); + + let c = new C(); + v = c; + assert(!(v === 3), "class === 3"); + assert(v !== 3, "class !== 3"); + assert(v === c, "class === the same class"); + assert(!(v === new C()), "class === another class"); +} + +function stringNumberOrNull() { + let u: string | number | null = "a"; + assert(u === "a", "string === a"); + assert(!(u === "b"), "string === b"); + assert(!(u === 1), "string === 1"); + + u = null; + assert(!(u === "a"), "null === a"); + assert(!(u === 1), "null === 1"); + + u = 1; + assert(u === 1, "number === 1"); + assert(!(u === "a"), "number === a"); +} + +// narrowed by typeof, `string | number | null` is left `string | null`: with strict null checks that is +// the string's own pointer, not a union with a tag, and the value is read out of the tagged one +function afterTypeOf(u: string | number | null) { + if (typeof u == "number") { + return 1; + } + + return u === "a" ? 2 : 3; +} + +function main() { + nullableNumber(); + classOrNumber(); + stringNumberOrNull(); + assert(afterTypeOf(5) == 1, "narrowed: number"); + assert(afterTypeOf("a") == 2, "narrowed: a"); + assert(afterTypeOf("b") == 3, "narrowed: b"); + assert(afterTypeOf(null) == 3, "narrowed: null"); + print("done."); +} From 1faca0bc4205f12b5cc861a6576d70704b16a4a8 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 1 Oct 2026 00:19:07 +0100 Subject: [PATCH 5/6] A value narrowed to null is null, also under --di `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 --- tslang/lib/TypeScript/MLIRGenImpl.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 957b0d557..af1500be9 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -3438,6 +3438,13 @@ class MLIRGenImpl castedValue = builder.create(location, safeType, exprValue); } } + else if (isa(safeType)) + { + // narrowed to null it is null - read out of `number[] | null`, which has no tag, it + // was the array struct cast to a pointer, and under --di its debug record kept that + // cast alive ("LLVM Translation failed for operation: builtin.unrealized_conversion_cast") + castedValue = builder.create(location, getNullType()); + } else { castedValue = builder.create(location, safeType, exprValue); From 2e0ffc4dcb68e4770e4593fe1e6929f47d0d3a09 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 1 Oct 2026 00:25:22 +0100 Subject: [PATCH 6/6] `n!` reads the member of a `number | null`; plain assignment is still 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 --- tslang/lib/TypeScript/MLIRGenCast.cpp | 21 ++++------------ tslang/lib/TypeScript/MLIRGenExpressions.cpp | 24 ++++++++++++++++++- .../tester/tests/00union_nullable_value.ts | 4 ++++ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index f5f0704cd..4307b67d0 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -1392,14 +1392,14 @@ namespace mlirgen } // `number | null`, `{ k: number } | null`: a value type or null is a union with a tag - one member - // and null. castFromUnion turns every member into the target type, and null is not most types + // and null. castFromUnion turns every member into the target type, and null is not a boolean // ("can't cast from 'null' to 'boolean'"), nor does it know an object type at all. Tested for - // truth it is false when it holds null, as text it is "null"; read as anything else it is its - // member - what narrowing (`if (p) p.k`) reads - unchecked, as `p!` is. + // truth it is false when it holds null, as text it is "null". Anything else stays with + // castFromUnion: `let x: number = n` is refused, as TypeScript refuses it - a narrowing or `n!` + // reads the member. std::optional MLIRGenImpl::castFromNullableUnion(mlir::Location location, mlir::Type type, mlir::Value value, mlir_ts::UnionType unionType, const GenContext &genContext) { - if (isa(type) || isa(type) || isa(type) - || isa(type) || isa(type)) + if (!isa(type) && !isa(type)) { return std::nullopt; } @@ -1429,17 +1429,6 @@ namespace mlirgen auto readMember = [&]() { return V(builder.create(location, memberType, value)); }; - if (!isa(type) && !isa(type)) - { - auto member = readMember(); - if (type == memberType) - { - return member; - } - - return cast(location, type, member, genContext); - } - auto nullValue = builder.create(location, getNullType()); auto isNull = builder.create( location, getBooleanType(), builder.getI32IntegerAttr((int)SyntaxKind::EqualsEqualsEqualsToken), value, nullValue); diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index e84e1828d..05c456b74 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -1291,7 +1291,29 @@ namespace mlirgen ValueOrLogicalResult MLIRGenImpl::mlirGen(NonNullExpression nonNullExpression, const GenContext &genContext) { - return mlirGen(nonNullExpression->expression, genContext); + auto result = mlirGen(nonNullExpression->expression, genContext); + EXIT_IF_FAILED_OR_NO_VALUE(result) + auto value = V(result); + + // `n!` of a `number | null` (a union with a tag, one member and null) is that member, read out + // unchecked - the way a narrowing reads it. Left a union, it was cast to the member as any + // union is, and null is not a number. + if (auto unionType = dyn_cast(value.getType())) + { + mlir::Type baseType; + if (mth.isUnionTypeNeedsTag(loc(nonNullExpression), unionType, baseType)) + { + SmallVector members; + llvm::copy_if(unionType.getTypes(), std::back_inserter(members), + [](mlir::Type member) { return !isa(member); }); + if (members.size() == 1 && members.size() != unionType.getTypes().size()) + { + return V(builder.create(loc(nonNullExpression), members.front(), value)); + } + } + } + + return value; } ValueOrLogicalResult MLIRGenImpl::mlirGen(OmittedExpression ommitedExpression, const GenContext &genContext) diff --git a/tslang/test/tester/tests/00union_nullable_value.ts b/tslang/test/tester/tests/00union_nullable_value.ts index 53f1fb240..bfafe88e3 100644 --- a/tslang/test/tester/tests/00union_nullable_value.ts +++ b/tslang/test/tester/tests/00union_nullable_value.ts @@ -41,6 +41,10 @@ function main() { assert(keyOf({ k: 5 }) == 5, "object: value"); assert(keyOf(null) == -1, "object: null"); + let m: number | null = 4; + let z: number = m!; + assert(z == 4, "non-null assertion reads the member"); + let n: number | null = 2; print(n); assert(`${n}` == "2", "number as text");