From c9941a35ffa1325401669e924ee00220e440d2b8 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 23:10:13 +0100 Subject: [PATCH 1/2] `T | null | undefined` accepts null, and an empty optional equals no value - Strict null checks: `string | null | undefined` (and a class) became optional without the null the checker needs, so null was a compile error. The null member now goes in before undefined wraps the type: optional>. - 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 --- .../LowerToLLVM/OptionalLogicHelper.h | 75 ++++++++-- .../LowerToLLVM/UnaryBinLogicalOrHelper.h | 17 ++- .../TypeScript/MLIRLogic/MLIRTypeHelper.h | 37 +++-- tslang/lib/TypeScript/MLIRGenCast.cpp | 39 ++++++ tslang/lib/TypeScript/MLIRGenImpl.h | 1 + tslang/lib/TypeScript/MLIRGenModule.cpp | 1 + tslang/test/tester/CMakeLists.txt | 6 +- .../array_literal_element_type.ts | 12 +- .../tester/tests/00union_null_undefined.ts | 128 ++++++++++++++++++ .../tests/00union_null_undefined_nonstrict.ts | 54 ++++++++ 10 files changed, 338 insertions(+), 32 deletions(-) create mode 100644 tslang/test/tester/tests/00union_null_undefined.ts create mode 100644 tslang/test/tester/tests/00union_null_undefined_nonstrict.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h b/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h index 57daa09e9..6df44a906 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/OptionalLogicHelper.h @@ -38,8 +38,6 @@ class OptionalLogicHelper template mlir::Value logicalOp(SyntaxKind opCmpCode) { - auto loc = binOp->getLoc(); - auto left = binOp->getOperand(0); auto right = binOp->getOperand(1); auto leftType = left.getType(); @@ -67,21 +65,68 @@ class OptionalLogicHelper return whenOneValueIsUndef(opCmpCode, right, left); } - // TODO: rewrite code to take in account that Opt value can be undefined - if (leftOptType) - { - auto leftSubType = leftOptType.getElementType(); - left = rewriter.create(loc, leftSubType, left); - } + return whenOneOptValue(opCmpCode); + } - if (rightOptType) - { - auto rightSubType = rightOptType.getElementType(); - right = rewriter.create(loc, rightSubType, right); - } + // One side is optional, the other a value. An optional holding undefined equals no value - not + // 0, not false, not null - so the values are compared only when it holds one: unwrapped without + // that test it read whatever the empty optional stored, and `undefined === 0` was true. + template + mlir::Value whenOneOptValue(SyntaxKind opCmpCode) + { + auto loc = binOp->getLoc(); + + TypeHelper th(rewriter); + CodeLogicHelper clh(binOp, rewriter); + + auto llvmBoolType = typeConverter.convertType(th.getBooleanType()); - return LogicOp(binOp, opCmpCode, left, left.getType(), right, right.getType(), rewriter, - typeConverter, compileOptions); + auto left = binOp->getOperand(0); + auto right = binOp->getOperand(1); + auto leftOptType = dyn_cast(left.getType()); + auto rightOptType = dyn_cast(right.getType()); + auto otherIsNull = isa((leftOptType ? right : left).getType()); + + auto hasValueBool = rewriter.create(loc, th.getBooleanType(), leftOptType ? left : right); + auto hasValue = rewriter.create(loc, llvmBoolType, hasValueBool); + + return clh.conditionalExpressionLowering( + loc, llvmBoolType, hasValue, + [&](OpBuilder &builder, Location loc) { + if (leftOptType) + { + left = rewriter.create(loc, leftOptType.getElementType(), left); + } + + if (rightOptType) + { + right = rewriter.create(loc, rightOptType.getElementType(), right); + } + + mlir::Value result = LogicOp( + binOp, opCmpCode, left, left.getType(), right, right.getType(), rewriter, typeConverter, compileOptions); + if (result && result.getType() != llvmBoolType) + { + result = rewriter.create(loc, llvmBoolType, result); + } + + return result; + }, + [&](OpBuilder &builder, Location loc) { + // undefined against a value: only "not equal" holds, and no ordering does - except + // that loosely undefined equals null + switch (opCmpCode) + { + case SyntaxKind::EqualsEqualsToken: + return clh.createI1ConstantOf(otherIsNull); + case SyntaxKind::ExclamationEqualsToken: + return clh.createI1ConstantOf(!otherIsNull); + case SyntaxKind::ExclamationEqualsEqualsToken: + return clh.createI1ConstantOf(true); + default: + return clh.createI1ConstantOf(false); + } + }); } template diff --git a/tslang/include/TypeScript/LowerToLLVM/UnaryBinLogicalOrHelper.h b/tslang/include/TypeScript/LowerToLLVM/UnaryBinLogicalOrHelper.h index 057654f66..4d0b119b3 100644 --- a/tslang/include/TypeScript/LowerToLLVM/UnaryBinLogicalOrHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/UnaryBinLogicalOrHelper.h @@ -101,6 +101,15 @@ mlir::Value LogicOp(Operation *binOp, SyntaxKind op, mlir::Value left, mlir::Typ LLVMTypeConverterHelper llvmtch(&typeConverter); + // A union's tag is read out of its LLVM struct. The lowering pattern hands over its operands + // converted already, but an optional's unwrapped value (OptionalLogicHelper) is still a ts.union. + auto asLLVMUnion = [&](mlir::Value value, mlir_ts::UnionType unionType) -> mlir::Value { + auto llvmUnionType = typeConverter.convertType(unionType); + return value.getType() == llvmUnionType + ? value + : builder.create(loc, llvmUnionType, value).getResult(); + }; + if (isa(leftType) || isa(rightType)) { return OptionalTypeLogicalOp(binOp, op, builder, typeConverter, compileOptions); @@ -212,8 +221,8 @@ mlir::Value LogicOp(Operation *binOp, SyntaxKind op, mlir::Value left, mlir::Typ mlir::Type baseType; if (mth.isUnionTypeNeedsTag(loc, unionType, baseType)) { - auto tagValue = builder.create(loc, llvmtch.typeConverter->convertType(mth.getStringType()), right, - MLIRHelper::getStructIndex(builder, UNION_TAG_INDEX)); + auto tagValue = builder.create(loc, llvmtch.typeConverter->convertType(mth.getStringType()), + asLLVMUnion(right, unionType), MLIRHelper::getStructIndex(builder, UNION_TAG_INDEX)); auto nullStr = builder.create(loc, mth.getStringType(), builder.getStringAttr("null")); auto cmpOp = builder.create( loc, mth.getBooleanType(), tagValue, nullStr, builder.getI32IntegerAttr((int)op)); @@ -302,8 +311,8 @@ mlir::Value LogicOp(Operation *binOp, SyntaxKind op, mlir::Value left, mlir::Typ right.getDefiningOp() || right.getDefiningOp() || matchPattern(right, m_Zero()); if (isRightNullValue) { - auto tagValue = builder.create(loc, llvmtch.typeConverter->convertType(mth.getStringType()), left, - MLIRHelper::getStructIndex(builder, UNION_TAG_INDEX)); + auto tagValue = builder.create(loc, llvmtch.typeConverter->convertType(mth.getStringType()), + asLLVMUnion(left, unionType), MLIRHelper::getStructIndex(builder, UNION_TAG_INDEX)); auto nullStr = builder.create(loc, mth.getStringType(), builder.getStringAttr("null")); auto cmpOp = builder.create( loc, mth.getBooleanType(), tagValue, nullStr, builder.getI32IntegerAttr((int)op)); diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h index a06688a7f..d88f0a948 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h @@ -41,6 +41,13 @@ class MLIRTypeHelper { } + // the options are a copy, taken before a file's `// @strict-null` pragma is read: the pragma + // has to reach this copy too, or unions are built strict in a file compiled non-strict + void setStrictNullChecks(bool strictNullChecks) + { + compileOptions.strictNullChecks = strictNullChecks; + } + MLIRTypeHelper( mlir::MLIRContext *context, CompileOptions compileOptions, @@ -3166,6 +3173,14 @@ class MLIRTypeHelper return getUnionTypeWithMerge(location, types, mergeLiterals, mergeTypes); } + // Without strict null checks a nullable type (string, a class) holds null in its own pointer, so + // null goes away beside it. A union of several types is a tagged value, not one pointer: there + // null has nowhere to go but a member of its own. + bool needsNullMember(mlir::ArrayRef types) + { + return llvm::count_if(types, [](mlir::Type type) { return !isa(type); }) > 1; + } + // TODO: review all union merge logic mlir::Type getUnionTypeMergeTypes(mlir::Location location, UnionTypeProcessContext &unionContext, bool mergeLiterals = true, bool mergeTypes = true, bool disableStrickNullCheck = false) { @@ -3213,17 +3228,22 @@ class MLIRTypeHelper if (typesAll.size() == 1) { auto resType = typesAll.front(); - if (unionContext.isUndefined) + + // the null the checker needs goes in before undefined wraps the type: undefined makes it + // optional, it must not take the null away - `string | null | undefined` accepts null as + // `string | null` does + if (compileOptions.strictNullChecks && !disableStrickNullCheck && unionContext.isNullable + && !isa(resType)) { - return mlir_ts::OptionalType::get(resType); - } + resType = mlir_ts::UnionType::get(context, {resType, getNullType()}); + } - if (compileOptions.strictNullChecks && !disableStrickNullCheck && unionContext.isNullable) + if (unionContext.isUndefined) { - return mlir_ts::UnionType::get(context, {resType, getNullType()}); + return mlir_ts::OptionalType::get(resType); } - return resType; + return resType; } // merge types @@ -3233,7 +3253,7 @@ class MLIRTypeHelper mlir::SmallVector mergedTypesAll; this->mergeTypes(location, typesAll, mergedTypesAll); - if (compileOptions.strictNullChecks && unionContext.isNullable) + if (unionContext.isNullable && (compileOptions.strictNullChecks || needsNullMember(mergedTypesAll))) { mergedTypesAll.push_back(getNullType()); } @@ -3247,7 +3267,8 @@ class MLIRTypeHelper return retType; } - if (compileOptions.strictNullChecks && !disableStrickNullCheck && unionContext.isNullable) + if (unionContext.isNullable + && ((compileOptions.strictNullChecks && !disableStrickNullCheck) || needsNullMember(typesAll))) { typesAll.push_back(getNullType()); } diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index 58df7e216..b44374677 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -679,6 +679,11 @@ namespace mlirgen return *result; } + if (auto result = castToNullableUnionType(location, type, value, valueType, genContext)) + { + return *result; + } + // const dest: cast via the unwrapped source type instead if (auto constType = dyn_cast(type)) { @@ -1342,6 +1347,40 @@ namespace mlirgen return std::nullopt; } + // `T | null` without a tag is T's own pointer, and null only there for the checker (strict null + // checks). A value of another type becomes a T first - an object literal an interface, a const + // array an array - the way it would be cast to T alone: cast straight to the union, that + // conversion was left undone for the lowering, which can't do it. + std::optional MLIRGenImpl::castToNullableUnionType(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext) + { + auto unionType = dyn_cast(type); + if (!unionType) + { + return std::nullopt; + } + + mlir::Type baseType; + if (mth.isUnionTypeNeedsTag(location, unionType, baseType) || !baseType || baseType == valueType) + { + return std::nullopt; + } + + if (isa(valueType) || isa(valueType) || isa(valueType) + || isa(valueType) || isa(valueType)) + { + return std::nullopt; + } + + auto types = unionType.getTypes(); + if (std::find(types.begin(), types.end(), valueType) != types.end()) + { + return std::nullopt; + } + + CAST(value, location, baseType, value, genContext); + return V(builder.create(location, type, value)); + } + std::optional MLIRGenImpl::castFromSourceSpecialCases(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext) { // union type to diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 8d45bd2af..1ae1e43c0 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -11335,6 +11335,7 @@ class MLIRGenImpl std::optional castToOptionalType(mlir::Location location, mlir::Type type, mlir::Value value, mlir::Type valueType, const GenContext &genContext); 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); // 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/lib/TypeScript/MLIRGenModule.cpp b/tslang/lib/TypeScript/MLIRGenModule.cpp index 6347a661d..2701423d3 100644 --- a/tslang/lib/TypeScript/MLIRGenModule.cpp +++ b/tslang/lib/TypeScript/MLIRGenModule.cpp @@ -160,6 +160,7 @@ namespace mlirgen if (option != strictNull->second.front().end()) { compileOptions.strictNullChecks = option->second._arg.value == S("true"); + mth.setStrictNullChecks(compileOptions.strictNullChecks); } } } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 10d66dbe4..2ed05fbf1 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -383,6 +383,8 @@ tslang_add_test(NAME test-compile-00-union-bin-ops-2 COMMAND test-runner "${PROJ tslang_add_test(NAME test-compile-00-union-to-any COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_to_any.ts") tslang_add_test(NAME test-compile-00-union-errors COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_errors.ts") tslang_add_test(NAME test-compile-00-union-null-to-string COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_to_string.ts") +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-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") @@ -849,6 +851,8 @@ tslang_add_test(NAME test-jit-00-union-bin-ops-2 COMMAND test-runner -jit "${PRO tslang_add_test(NAME test-jit-00-union-to-any COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_to_any.ts") tslang_add_test(NAME test-jit-00-union-errors COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_errors.ts") tslang_add_test(NAME test-jit-00-union-null-to-string COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_to_string.ts") +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-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") @@ -2460,7 +2464,7 @@ add_test(NAME test-compile-lowering-error-message COMMAND $ --emit=obj --no-default-lib -mm=none "${lowering_error_file}" -o "${CMAKE_CURRENT_BINARY_DIR}/lowering-error-message.obj") set_tests_properties(test-compile-lowering-error-message - PROPERTIES PASS_REGULAR_EXPRESSION "source array and destination array have different types" + PROPERTIES PASS_REGULAR_EXPRESSION "cast from array type .* to array type .* is not supported" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") add_test(NAME test-compile-lowering-error-exit COMMAND $ --emit=obj --no-default-lib -mm=none "${lowering_error_file}" 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 7d87efc35..e6ae4f3c0 100644 --- a/tslang/test/tester/lowering-errors/array_literal_element_type.ts +++ b/tslang/test/tester/lowering-errors/array_literal_element_type.ts @@ -1,8 +1,12 @@ -// Lowering cannot build this array literal as 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 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. // (The array-to-string cast this test used first hits llvm_unreachable in a Debug build before it -// reports anything; this error is reported the same way in both.) +// 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; +} + function main() { - let a: number[] | null = [1, 2]; + print(lengthOf(null)); print("done."); } diff --git a/tslang/test/tester/tests/00union_null_undefined.ts b/tslang/test/tester/tests/00union_null_undefined.ts new file mode 100644 index 000000000..5eca0b9ed --- /dev/null +++ b/tslang/test/tester/tests/00union_null_undefined.ts @@ -0,0 +1,128 @@ +// `T | null | undefined` accepts null as `T | null` does: undefined makes it optional, it must not take +// the null away - for a nullable T (string, a class), whose null is only there for the type checker, +// and for a value T (number), whose null is a real member of the union +class C { + x = 1; +} + +function isNullString(p: string | null | undefined) { + return p === null; +} + +function isUndefinedString(p: string | null | undefined) { + return p === undefined; +} + +function nullableString() { + let s: string | null | undefined = null; + assert(s === null, "string: null is null"); + assert(s !== undefined, "string: null is not undefined"); + + s = undefined; + assert(s === undefined, "string: undefined is undefined"); + assert(s !== null, "string: undefined is not null"); + + s = "a"; + assert(s === "a", "string: value"); + assert(s !== null && s !== undefined, "string: value is neither"); + + let r: undefined | null | string = null; + assert(r === null, "string, other order: null is null"); + + assert(isNullString(null), "string argument: null"); + assert(!isNullString(undefined), "string argument: undefined is not null"); + assert(isUndefinedString(undefined), "string argument: undefined"); + assert(!isUndefinedString(null), "string argument: null is not undefined"); + assert(!isNullString("b"), "string argument: value"); +} + +function isNullClass(p: C | null | undefined) { + return p === null; +} + +function nullableClass() { + let c: C | null | undefined = null; + assert(c === null, "class: null is null"); + assert(c !== undefined, "class: null is not undefined"); + + c = undefined; + assert(c === undefined, "class: undefined is undefined"); + + c = new C(); + assert(c !== null && c !== undefined, "class: value is neither"); + assert(c.x == 1, "class: value field"); + + assert(isNullClass(null), "class argument: null"); + assert(!isNullClass(undefined), "class argument: undefined is not null"); +} + +function isNullNumber(p: number | null | undefined) { + return p === null; +} + +function valueNumber() { + let n: number | null | undefined = null; + assert(n === null, "number: null is null"); + assert(n !== undefined, "number: null is not undefined"); + + n = undefined; + assert(n === undefined, "number: undefined is undefined"); + + n = 2; + assert(n !== null && n !== undefined, "number: value is neither"); + + assert(isNullNumber(null), "number argument: null"); + assert(!isNullNumber(undefined), "number argument: undefined is not null"); + assert(!isNullNumber(3), "number argument: value"); +} + +// an optional holding undefined equals nothing - loosely, it equals null +function emptyOptional() { + let o: string | undefined = undefined; + assert(o !== null, "empty optional: not strictly null"); + assert(o == null, "empty optional: loosely null"); + assert(!(o != null), "empty optional: not loosely other than null"); + assert(o !== "", "empty optional: not an empty string"); + + let b: boolean | undefined = undefined; + assert(b !== false, "empty optional: not false"); +} + +interface I { + k: number; +} + +class B { + k: number = 2; +} + +function keyOf(p: I | null | undefined) { + return p ? p.k : -1; +} + +function nullableKeyOf(p: I | null) { + return p ? p.k : -1; +} + +// what `T | null` takes is cast to T first: an object literal to an interface, a const array to an array +function castToNullable() { + assert(keyOf({ k: 1 }) == 1, "object literal to optional nullable interface"); + assert(keyOf(null) == -1, "null to optional nullable interface"); + assert(keyOf(undefined) == -1, "undefined to optional nullable interface"); + assert(nullableKeyOf({ k: 3 }) == 3, "object literal to nullable interface"); + assert(nullableKeyOf(new B()) == 2, "class to nullable interface"); + + let a: number[] | null = [1, 2]; + assert(a !== null, "const array to nullable array"); + a = null; + assert(a === null, "null to nullable array"); +} + +function main() { + nullableString(); + nullableClass(); + valueNumber(); + emptyOptional(); + castToNullable(); + print("done."); +} diff --git a/tslang/test/tester/tests/00union_null_undefined_nonstrict.ts b/tslang/test/tester/tests/00union_null_undefined_nonstrict.ts new file mode 100644 index 000000000..d937c097b --- /dev/null +++ b/tslang/test/tester/tests/00union_null_undefined_nonstrict.ts @@ -0,0 +1,54 @@ +// @strict-null false +// Without strict null checks a nullable type (string, a class) absorbs null: its pointer holds it. A +// union with a nullable member is no pointer, though, so there null has to stay a member of its own +class C { + x = 1; +} + +function isNullUnion(p: string | number | null) { + return p === null; +} + +function unionWithNullableMember() { + let u: string | number | null = null; + assert(u === null, "union: null is null"); + + u = "a"; + assert(u === "a", "union: string"); + + u = 1; + assert(u === 1, "union: number"); + + assert(isNullUnion(null), "union argument: null"); + assert(!isNullUnion("b"), "union argument: string"); + assert(!isNullUnion(2), "union argument: number"); + + let v: C | number | null = null; + assert(v === null, "class union: null is null"); + v = 3; + assert(v !== null, "class union: number is not null"); +} + +function nullableOnly() { + let s: string | null = null; + assert(s === null, "string: null"); + + let o: string | null | undefined = null; + assert(o === null, "optional string: null"); + o = undefined; + assert(o === undefined, "optional string: undefined"); + + let c: C | null | undefined = null; + assert(c === null, "optional class: null"); + + let n: number | null | undefined = null; + assert(n === null, "optional number: null"); + n = undefined; + assert(n === undefined, "optional number: undefined"); +} + +function main() { + unionWithNullableMember(); + nullableOnly(); + print("done."); +} From bf7c1bed89cd5b584cd00803f204790c86e96bb4 Mon Sep 17 00:00:00 2001 From: Alex D Date: Wed, 30 Sep 2026 23:23:03 +0100 Subject: [PATCH 2/2] An optional number compares as the wider type, and only when it holds 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 --- tslang/lib/TypeScript/MLIRGenImpl.h | 63 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 2 + .../tester/tests/00optional_number_compare.ts | 56 +++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tslang/test/tester/tests/00optional_number_compare.ts diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 1ae1e43c0..207b34e28 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -5861,6 +5861,54 @@ class MLIRGenImpl return mlir::success(); } + // An optional number compared with a number. The lowering compares them only when the optional + // holds a value (OptionalLogicHelper), so it stays optional here: synced as a plain value it was + // cast out of the optional first, an empty one reading as 0 - and the promotion saw only the other + // operand's type, so against an integer literal 0.5 became an s32 and `0.5 === 0` was true. Both + // sides take the wider of the optional's element type and the other operand's type. + ValueOrLogicalResult syncOptionalNumericTypes(mlir::Location location, mlir::Value &leftExpressionValue, mlir::Value &rightExpressionValue, const GenContext &genContext) + { + auto leftOptType = dyn_cast(leftExpressionValue.getType()); + auto rightOptType = dyn_cast(rightExpressionValue.getType()); + if (!leftOptType == !rightOptType) + { + return mlir::success(); + } + + auto &optValue = leftOptType ? leftExpressionValue : rightExpressionValue; + auto &otherValue = leftOptType ? rightExpressionValue : leftExpressionValue; + auto elementType = (leftOptType ? leftOptType : rightOptType).getElementType(); + auto otherType = otherValue.getType(); + if (auto literalType = dyn_cast(otherType)) + { + otherType = literalType.getElementType(); + } + + if (!isPromotableNumeric(elementType) || !isPromotableNumeric(otherType)) + { + return mlir::success(); + } + + auto types = numericPromotionOrder(); + auto commonType = llvm::find_if(types, [&](mlir::Type type) { return type == elementType || type == otherType; }); + if (commonType == types.end()) + { + return mlir::success(); + } + + if (elementType != *commonType) + { + CAST(optValue, location, getOptionalType(*commonType), optValue, genContext); + } + + if (otherValue.getType() != *commonType) + { + CAST(otherValue, location, *commonType, otherValue, genContext); + } + + return optValue; + } + // The order the arithmetic operators promote in, widest first: the first type either // operand already has is the one both are cast to. Shared by `+` and by the general // arithmetic/comparison path below, which is the point -- they disagreed, and `+` was @@ -6169,6 +6217,21 @@ class MLIRGenImpl // TODO: do we need to sync type for all Ops? auto types = numericPromotionOrder(); + // `-` and `*` fall into this case too: arithmetic on an optional is left as it was + if (opCode != SyntaxKind::MinusToken && opCode != SyntaxKind::AsteriskToken) + { + auto o = syncOptionalNumericTypes(location, leftExpressionValue, rightExpressionValue, genContext); + if (o.value) + { + break; + } + + if (mlir::failed(o.result)) + { + return mlir::failure(); + } + } + auto r = syncUnionTypes(location, leftExpressionValue, rightExpressionValue, genContext); if (r.value) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2ed05fbf1..e6d8c70b0 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -385,6 +385,7 @@ tslang_add_test(NAME test-compile-00-union-errors COMMAND test-runner "${PROJECT tslang_add_test(NAME test-compile-00-union-null-to-string COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_to_string.ts") 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-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") @@ -853,6 +854,7 @@ tslang_add_test(NAME test-jit-00-union-errors COMMAND test-runner -jit "${PROJEC tslang_add_test(NAME test-jit-00-union-null-to-string COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_null_to_string.ts") 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-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_number_compare.ts b/tslang/test/tester/tests/00optional_number_compare.ts new file mode 100644 index 000000000..fa2cd3a88 --- /dev/null +++ b/tslang/test/tester/tests/00optional_number_compare.ts @@ -0,0 +1,56 @@ +// An optional number compares as the wider of its own type and the other operand's - not as the +// other operand's alone, which made 0.5 an s32 against an integer literal - and only when it holds a +// value: an empty one is not 0, it is less than, greater than and equal to nothing +function holdsFraction() { + let n: number | undefined = 0.5; + assert(!(n === 0), "0.5 === 0"); + assert(n === 0.5, "0.5 === 0.5"); + assert(n !== 0, "0.5 !== 0"); + assert(n < 1, "0.5 < 1"); + assert(n > 0, "0.5 > 0"); + assert(!(0 === n), "0 === 0.5"); + assert(0.5 == n, "0.5 == 0.5"); + + let zero: number = 0; + let half: number = 0.5; + assert(!(n === zero), "0.5 === zero"); + assert(n === half, "0.5 === half"); + assert(n > zero, "0.5 > zero"); +} + +function empty() { + let n: number | undefined = undefined; + assert(!(n === 0), "undefined === 0"); + assert(!(n == 0), "undefined == 0"); + assert(n !== 0, "undefined !== 0"); + assert(!(n < 1), "undefined < 1"); + assert(!(n >= 0), "undefined >= 0"); + assert(!(0 === n), "0 === undefined"); + + let zero: number = 0; + assert(!(n === zero), "undefined === zero"); + assert(!(n <= zero), "undefined <= zero"); +} + +function integers() { + let i: s32 | undefined = 3; + assert(i === 3, "s32 3 === 3"); + assert(!(i === 0), "s32 3 === 0"); + assert(i > 2, "s32 3 > 2"); + assert(i < 3.5, "s32 3 < 3.5"); + + i = undefined; + assert(!(i === 0), "s32 undefined === 0"); + assert(!(i < 1), "s32 undefined < 1"); + + let f: number | undefined = 3; + assert(f === 3, "3.0 === 3"); + assert(f > 2.5, "3.0 > 2.5"); +} + +function main() { + holdsFraction(); + empty(); + integers(); + print("done."); +}