From 2135be8d2938ab03bdb973fc5982dfc9bc460da3 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sun, 5 Jul 2026 16:01:32 -0700 Subject: [PATCH 1/5] fix(types): reject symbol operands in arithmetic/comparison/unary operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Symbol.for(key) typed as Any instead of symbol. Symbol() (bare call) was already special-cased to return TypeInfo.Symbol, but Symbol.for's callee shape (Expr.Get on the Symbol global, not a bare Expr.Variable call) didn't match that special case, so it fell through to evaluating the bare `Symbol` identifier (which resolves to Any for unrelated reasons) and calling `.for` on Any stayed Any. Every arithmetic/comparison/unary operand check downstream silently no-oped as a result, since Any bypasses all of them. Added Symbol.for/keyFor alongside the existing Symbol() case. With Symbol.for correctly typed, the operator checks themselves turned out to be missing or using the wrong diagnostic code for symbol operands specifically, across nearly every operator category: - Arithmetic/bitwise/unsigned-shift (*, /, %, <<, >>, >>>, &, ^, |) and their compound-assign forms: tsc reports each invalid operand independently (TS2362 left, TS2363 right — both can fire on one expression). New shared RecordInvalidArithmeticOperand helper. - Comparison (<, >, <=, >=): symbol gets its own single-diagnostic message (TS2469) instead of the generic two-type TS2365. - Unary -/~: same TS2469 branch. Unary + had NO handling at all (silently passed any operand through unchanged) — added the missing branch, scoped to just the symbol rejection. - Binary + and += (CheckPlusOperator/CheckCompoundAssign): most nuanced — tsc's message choice depends on the other operand type (symbol+symbol or symbol+number get the generic TS2365; symbol paired with string/any/a union merely containing symbol get the symbol-specific TS2469). New ContainsSymbolType helper (true for a union with *any* symbol constituent, unlike an all-constituents check) and IsBareNumberType (deliberately not Any-permissive, so `any` isn't mistaken for "the other side was a number"). Also fixes prefix/postfix increment/decrement's line attribution (no explicit line was passed, so the diagnostic landed on whatever statement `_currentStatementLine` happened to be instead of the ++/-- itself) and widens them to accept bigint per tsc's own message wording. Found and fixed a real regression along the way: an early version of RecordInvalidArithmeticOperand only recorded diagnostics (never threw), which silently broke exception-based single-shot checking (two existing xUnit tests, and the default interpreter path in general) for ANY invalid arithmetic operand, not just symbol. Fixed by recording the diagnostic that won't be thrown directly, then throwing the other — single-shot callers still see an exception, and recovery mode's per-statement catch still ends up with both diagnostics. Removed IsSymbolType as dead code once superseded by ContainsSymbolType everywhere; its Any-permissive construction had also caused a second bug (any wrongly "matched" the symbol predicate). Flips symbolType4/5/6/7/8/10/12 from Fail to Pass, 0 regressions. --- TypeSystem/TypeChecker.Calls.cs | 28 +++++ .../TypeChecker.Compatibility.Helpers.cs | 15 +++ TypeSystem/TypeChecker.Operators.cs | 118 ++++++++++++++++-- 3 files changed, 150 insertions(+), 11 deletions(-) diff --git a/TypeSystem/TypeChecker.Calls.cs b/TypeSystem/TypeChecker.Calls.cs index 33f2c3d2..bdda1f2b 100644 --- a/TypeSystem/TypeChecker.Calls.cs +++ b/TypeSystem/TypeChecker.Calls.cs @@ -365,6 +365,34 @@ private bool TryCheckBuiltinCall(Expr.Call call, out TypeInfo result) { result = new TypeInfo.Symbol(); return true; } } + // Handle Symbol.for(key) - returns a shared symbol from the global registry. `Symbol` bare + // resolves to Any (LookupVariable), so without this the call fell through and stayed Any too + // — silently bypassing every arithmetic/comparison operand check a real `symbol` would trip. + if (call.Callee is Expr.Get { Object: Expr.Variable { Name.Lexeme: "Symbol" }, Name.Lexeme: "for" }) + { + if (call.Arguments.Count != 1) + { + throw new TypeCheckException("Symbol.for() requires exactly one argument.", tsCode: "TS2554"); + } + var argType = CheckExpr(call.Arguments[0]); + if (!IsString(argType) && argType is not TypeInfo.Any) + { + throw new TypeCheckException($"Symbol.for() argument must be a string, got '{argType}'.", tsCode: "TS2345"); + } + { result = new TypeInfo.Symbol(); return true; } + } + + // Handle Symbol.keyFor(sym) - returns the key registered for a symbol, or undefined if none. + if (call.Callee is Expr.Get { Object: Expr.Variable { Name.Lexeme: "Symbol" }, Name.Lexeme: "keyFor" }) + { + if (call.Arguments.Count != 1) + { + throw new TypeCheckException("Symbol.keyFor() requires exactly one argument.", tsCode: "TS2554"); + } + CheckExpr(call.Arguments[0]); + { result = new TypeInfo.Union([new TypeInfo.String(), new TypeInfo.Undefined()]); return true; } + } + // Handle BigInt() constructor - converts number or string to bigint if (call.Callee is Expr.Variable bigIntVar && bigIntVar.Name.Lexeme == "BigInt") { diff --git a/TypeSystem/TypeChecker.Compatibility.Helpers.cs b/TypeSystem/TypeChecker.Compatibility.Helpers.cs index f8fb5add..68601e95 100644 --- a/TypeSystem/TypeChecker.Compatibility.Helpers.cs +++ b/TypeSystem/TypeChecker.Compatibility.Helpers.cs @@ -35,6 +35,21 @@ type is TypeInfo.String || private bool IsBigInt(TypeInfo t) => IsTypeOfKind(t, type => type is TypeInfo.BigInt or TypeInfo.BigIntLiteral); + /// + /// True if is symbol, or a union with AT LEAST ONE symbol constituent. + /// Deliberately NOT built on the Any-permissive (which would make + /// `any` satisfy this too) — operator operand checks use this to decide whether tsc's + /// symbol-specific rejection applies, e.g. `(sym || "") + ""` still gets "The '+' operator + /// cannot be applied to type 'symbol'" even though the left side isn't purely symbol, while + /// `sym + any` must NOT be treated as "both sides are symbol". + /// + private static bool ContainsSymbolType(TypeInfo t) => t switch + { + TypeInfo.Symbol or TypeInfo.UniqueSymbol => true, + TypeInfo.Union u => u.FlattenedTypes.Any(ContainsSymbolType), + _ => false, + }; + /// /// Checks if a type is a primitive (not valid as WeakMap key or WeakSet value). /// diff --git a/TypeSystem/TypeChecker.Operators.cs b/TypeSystem/TypeChecker.Operators.cs index 56988741..7e623170 100644 --- a/TypeSystem/TypeChecker.Operators.cs +++ b/TypeSystem/TypeChecker.Operators.cs @@ -25,7 +25,7 @@ private TypeInfo CheckBinary(Expr.Binary binary) { OperatorDescriptor.Plus => CheckPlusOperator(left, right, line), OperatorDescriptor.Arithmetic or OperatorDescriptor.Power => CheckArithmeticBinary(left, right, line), - OperatorDescriptor.Comparison => CheckComparisonBinary(left, right, line), + OperatorDescriptor.Comparison => CheckComparisonBinary(left, right, binary.Operator.Lexeme, line), OperatorDescriptor.Equality => new TypeInfo.Primitive(TokenType.TYPE_BOOLEAN), OperatorDescriptor.Bitwise or OperatorDescriptor.BitwiseShift => CheckBitwiseBinary(left, right, line), OperatorDescriptor.UnsignedRightShift => CheckUnsignedShiftBinary(left, right, line), @@ -45,6 +45,25 @@ t is TypeInfo.Any or TypeInfo.Inferred private TypeInfo CheckPlusOperator(TypeInfo left, TypeInfo right, int line = 0) { + // symbol poisons '+' even against `any` (unlike every other operator category here, which + // bypasses on `any`) — checked first, before the any-bypass below. tsc picks between two + // diagnostics depending on the OTHER operand: a bare number opposite symbol (or symbol on + // both sides) gets the generic "cannot be applied to types 'A' and 'B'"; anything else + // (string, any, or a union merely containing symbol like `sym || ""`) gets the + // symbol-specific "The '+' operator cannot be applied to type 'symbol'". + bool leftHasSymbol = ContainsSymbolType(left); + bool rightHasSymbol = ContainsSymbolType(right); + if (leftHasSymbol || rightHasSymbol) + { + // A plain pattern match, not a helper built on the Any-permissive IsTypeOfKind (which + // would treat `any` as satisfying "is symbol" and wrongly make `s + a` look pure-symbol). + bool bothPureSymbol = left is TypeInfo.Symbol or TypeInfo.UniqueSymbol + && right is TypeInfo.Symbol or TypeInfo.UniqueSymbol; + bool otherIsBareNumber = (leftHasSymbol && IsBareNumberType(right)) || (rightHasSymbol && IsBareNumberType(left)); + if (bothPureSymbol || otherIsBareNumber) + throw new TypeCheckException($"Operator '+' cannot be applied to types '{left}' and '{right}'.", line > 0 ? line : null, tsCode: "TS2365"); + throw new TypeCheckException("The '+' operator cannot be applied to type 'symbol'.", line > 0 ? line : null, tsCode: "TS2469"); + } // Any/Inferred (incl. in unions) bypasses type checking - return any if (IsAnyPermissive(left) || IsAnyPermissive(right)) return new TypeInfo.Any(); if (IsBigInt(left) && IsBigInt(right)) return new TypeInfo.BigInt(); @@ -55,6 +74,12 @@ private TypeInfo CheckPlusOperator(TypeInfo left, TypeInfo right, int line = 0) throw new TypeCheckException("Operator '+' cannot be applied to types '" + left + "' and '" + right + "'.", line > 0 ? line : null, tsCode: "TS2365"); } + /// Exactly a number primitive or number-literal type — deliberately NOT the + /// Any-permissive (which treats `any` as "is a number" for ordinary + /// operand compatibility checks); this is used to pick between two symbol-vs-other-operand + /// diagnostic messages where `any` must NOT be treated as "the other side was a number". + private static bool IsBareNumberType(TypeInfo t) => t is TypeInfo.Primitive { Type: TokenType.TYPE_NUMBER } or TypeInfo.NumberLiteral; + private TypeInfo CheckArithmeticBinary(TypeInfo left, TypeInfo right, int line = 0) { // Any/Inferred (incl. in unions) bypasses type checking @@ -67,10 +92,31 @@ private TypeInfo CheckArithmeticBinary(TypeInfo left, TypeInfo right, int line = return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); if ((IsBigInt(left) && IsNumber(right)) || (IsNumber(left) && IsBigInt(right))) throw new TypeCheckException("Cannot mix bigint and number in arithmetic operations. Use explicit BigInt() or Number() conversion.", line > 0 ? line : null, tsCode: "TS2365"); - throw new TypeCheckException($"Operands must be numbers or bigints of the same type. Got '{left}' and '{right}'.", line > 0 ? line : null, tsCode: "TS2362"); + RecordInvalidArithmeticOperand(left, right, line); + return new TypeInfo.Any(); + } + + /// + /// tsc reports each invalid arithmetic/bitwise operand independently — TS2362 for the left side, + /// TS2363 for the right — so both can fire on the same expression (`s * s` where `s: symbol` + /// gets two diagnostics, `s * 0` gets only the one for `s`). Always throws, like every other + /// operator check in this file, so single-shot (non-recovery) callers still see an exception; + /// when BOTH sides are invalid, the one NOT thrown is recorded directly first, so recovery + /// mode's per-statement catch (which records whichever one propagates) still ends up with both. + /// + private void RecordInvalidArithmeticOperand(TypeInfo left, TypeInfo right, int line) + { + const string message = "An arithmetic operand must be of type 'any', 'number', 'bigint' or an enum type."; + bool leftInvalid = !IsNumber(left) && !IsBigInt(left); + bool rightInvalid = !IsNumber(right) && !IsBigInt(right); + if (leftInvalid && rightInvalid) + RecordTypeError(new TypeCheckException(message, line: line > 0 ? line : null, tsCode: "TS2363")); + if (leftInvalid) + throw new TypeCheckException(message, line > 0 ? line : null, tsCode: "TS2362"); + throw new TypeCheckException(message, line > 0 ? line : null, tsCode: "TS2363"); } - private TypeInfo CheckComparisonBinary(TypeInfo left, TypeInfo right, int line = 0) + private TypeInfo CheckComparisonBinary(TypeInfo left, TypeInfo right, string op, int line = 0) { // Any/Inferred (incl. in unions) bypasses type checking if (IsAnyPermissive(left) || IsAnyPermissive(right)) @@ -83,6 +129,10 @@ private TypeInfo CheckComparisonBinary(TypeInfo left, TypeInfo right, int line = return new TypeInfo.Primitive(TokenType.TYPE_BOOLEAN); if ((IsBigInt(left) && IsNumber(right)) || (IsNumber(left) && IsBigInt(right))) throw new TypeCheckException("Cannot compare bigint and number directly. Use explicit conversion.", line > 0 ? line : null, tsCode: "TS2365"); + // tsc has a symbol-specific message for relational comparisons (one diagnostic for the whole + // expression, unlike the arithmetic per-side TS2362/2363 split above). + if (ContainsSymbolType(left) || ContainsSymbolType(right)) + throw new TypeCheckException($"The '{op}' operator cannot be applied to type 'symbol'.", line > 0 ? line : null, tsCode: "TS2469"); throw new TypeCheckException($"Comparison operands must be numbers, bigints, or strings of the same type. Got '{left}' and '{right}'.", line > 0 ? line : null, tsCode: "TS2365"); } @@ -98,7 +148,9 @@ private TypeInfo CheckBitwiseBinary(TypeInfo left, TypeInfo right, int line = 0) return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); if ((IsBigInt(left) && IsNumber(right)) || (IsNumber(left) && IsBigInt(right))) throw new TypeCheckException("Cannot mix bigint and number in bitwise operations.", line > 0 ? line : null, tsCode: "TS2365"); - throw new TypeCheckException($"Bitwise operators require numeric operands. Got '{left}' and '{right}'.", line > 0 ? line : null, tsCode: "TS2365"); + // Same per-side TS2362/TS2363 split as arithmetic — tsc uses the identical codes for bitwise. + RecordInvalidArithmeticOperand(left, right, line); + return new TypeInfo.Any(); } private TypeInfo CheckUnsignedShiftBinary(TypeInfo left, TypeInfo right, int line = 0) @@ -110,7 +162,18 @@ private TypeInfo CheckUnsignedShiftBinary(TypeInfo left, TypeInfo right, int lin if (IsBigInt(left) || IsBigInt(right)) throw new TypeCheckException("Unsigned right shift (>>>) is not supported for bigint.", line > 0 ? line : null, tsCode: "TS2791"); if (!IsNumber(left) || !IsNumber(right)) - throw new TypeCheckException("Bitwise operators require numeric operands.", line > 0 ? line : null, tsCode: "TS2365"); + { + // Same per-side TS2362/TS2363 split as the other arithmetic/bitwise operators (see + // RecordInvalidArithmeticOperand) — bigint is excluded above (not a valid >>> operand + // at all), so only number qualifies here. Always throws; when both sides are invalid, + // the one not thrown is recorded directly so recovery mode still gets both. + const string message = "An arithmetic operand must be of type 'any', 'number', 'bigint' or an enum type."; + if (!IsNumber(left) && !IsNumber(right)) + RecordTypeError(new TypeCheckException(message, line: line > 0 ? line : null, tsCode: "TS2363")); + if (!IsNumber(left)) + throw new TypeCheckException(message, line > 0 ? line : null, tsCode: "TS2362"); + throw new TypeCheckException(message, line > 0 ? line : null, tsCode: "TS2363"); + } return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); } @@ -349,19 +412,37 @@ private TypeInfo CheckCompoundAssign(Expr.CompoundAssign compound) var assignedPath = new Narrowing.NarrowingPath.Variable(compound.Name.Lexeme); InvalidateNarrowingsFor(assignedPath); + int line = compound.Operator.Line; + // For += with strings, allow string concatenation if (compound.Operator.Type == TokenType.PLUS_EQUAL) { + // Same symbol-vs-other-operand branching as binary '+' (CheckPlusOperator) — checked + // FIRST, ahead of the string-concatenation shortcut below: symbol poisons '+=' even + // against `any` or a string LHS (`str += sym` still errors). + bool leftHasSymbol = ContainsSymbolType(varType); + bool rightHasSymbol = ContainsSymbolType(valueType); + if (leftHasSymbol || rightHasSymbol) + { + bool bothPureSymbol = varType is TypeInfo.Symbol or TypeInfo.UniqueSymbol + && valueType is TypeInfo.Symbol or TypeInfo.UniqueSymbol; + bool otherIsBareNumber = (leftHasSymbol && IsBareNumberType(valueType)) || (rightHasSymbol && IsBareNumberType(varType)); + if (bothPureSymbol || otherIsBareNumber) + throw new TypeCheckException($"Operator '+=' cannot be applied to types '{varType}' and '{valueType}'.", line: line > 0 ? line : null, tsCode: "TS2365"); + throw new TypeCheckException("The '+=' operator cannot be applied to type 'symbol'.", line: line > 0 ? line : null, tsCode: "TS2469"); + } + if (IsString(varType)) return varType; if (!IsNumber(varType) || !IsNumber(valueType)) throw new TypeCheckException("Compound assignment requires numeric operands.", tsCode: "TS2365"); return varType; } - // All other compound operators require numbers + // All other compound operators require numbers — same per-side TS2362/TS2363 split as the + // binary arithmetic/bitwise operators. if (!IsNumber(varType) || !IsNumber(valueType)) { - throw new TypeCheckException("Compound assignment requires numeric operands.", tsCode: "TS2365"); + RecordInvalidArithmeticOperand(varType, valueType, line); } return varType; @@ -590,9 +671,9 @@ private TypeInfo CheckLogicalSetIndex(Expr.LogicalSetIndex logical) private TypeInfo CheckPrefixIncrement(Expr.PrefixIncrement prefix) { TypeInfo operandType = CheckExpr(prefix.Operand); - if (!IsNumber(operandType)) + if (!IsNumber(operandType) && !IsBigInt(operandType)) { - throw new TypeCheckException("Increment/decrement operand must be a number.", tsCode: "TS2356"); + throw new TypeCheckException("An arithmetic operand must be of type 'any', 'number', 'bigint' or an enum type.", line: prefix.Operator.Line, tsCode: "TS2356"); } return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); } @@ -600,9 +681,9 @@ private TypeInfo CheckPrefixIncrement(Expr.PrefixIncrement prefix) private TypeInfo CheckPostfixIncrement(Expr.PostfixIncrement postfix) { TypeInfo operandType = CheckExpr(postfix.Operand); - if (!IsNumber(operandType)) + if (!IsNumber(operandType) && !IsBigInt(operandType)) { - throw new TypeCheckException("Increment/decrement operand must be a number.", tsCode: "TS2356"); + throw new TypeCheckException("An arithmetic operand must be of type 'any', 'number', 'bigint' or an enum type.", line: postfix.Operator.Line, tsCode: "TS2356"); } return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); } @@ -665,6 +746,9 @@ private TypeInfo CheckUnary(Expr.Unary unary) if (right is TypeInfo.Any) return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); if (IsBigInt(right)) return new TypeInfo.BigInt(); if (IsNumber(right)) return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); + // tsc has a symbol-specific message for unary '-'/'~'/'+', distinct from the generic one. + if (ContainsSymbolType(right)) + throw new TypeCheckException("The '-' operator cannot be applied to type 'symbol'.", tsCode: "TS2469"); throw new TypeCheckException("Unary '-' expects a number or bigint.", tsCode: "TS2362"); } if (unary.Operator.Type == TokenType.BANG) @@ -675,8 +759,20 @@ private TypeInfo CheckUnary(Expr.Unary unary) if (right is TypeInfo.Any) return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); if (IsBigInt(right)) return new TypeInfo.BigInt(); if (IsNumber(right)) return new TypeInfo.Primitive(TokenType.TYPE_NUMBER); + if (ContainsSymbolType(right)) + throw new TypeCheckException("The '~' operator cannot be applied to type 'symbol'.", tsCode: "TS2469"); throw new TypeCheckException("Bitwise NOT requires a numeric operand.", tsCode: "TS2362"); } + if (unary.Operator.Type == TokenType.PLUS) + { + // Unary '+' had no check at all — any operand (including symbol) silently passed + // through unchanged. Add only the symbol-specific rejection tsc requires here; + // leave the (separately incorrect, pre-existing) passthrough for every other operand + // type alone rather than widen this fix into "unary + always coerces to number". + if (ContainsSymbolType(right)) + throw new TypeCheckException("The '+' operator cannot be applied to type 'symbol'.", tsCode: "TS2469"); + return right; + } return right; } From 1d46c5a1531a178a4831b660570e1736c1370b55 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sun, 5 Jul 2026 16:01:40 -0700 Subject: [PATCH 2/5] =?UTF-8?q?test(conformance):=20update=20baseline=20?= =?UTF-8?q?=E2=80=94=20symbolType4/5/6/7/8/10/12=20now=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 209 -> 216 Pass. 0 regressions. --- .../baselines/interpreted.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/SharpTS.TypeScriptConformance/baselines/interpreted.txt b/SharpTS.TypeScriptConformance/baselines/interpreted.txt index 0d9fe2f1..f3304492 100644 --- a/SharpTS.TypeScriptConformance/baselines/interpreted.txt +++ b/SharpTS.TypeScriptConformance/baselines/interpreted.txt @@ -144,9 +144,9 @@ tests/cases/conformance/es6/Symbols/symbolProperty7.ts Fail tests/cases/conformance/es6/Symbols/symbolProperty8.ts Pass tests/cases/conformance/es6/Symbols/symbolProperty9.ts Fail tests/cases/conformance/es6/Symbols/symbolType1.ts Fail -tests/cases/conformance/es6/Symbols/symbolType10.ts Fail +tests/cases/conformance/es6/Symbols/symbolType10.ts Pass tests/cases/conformance/es6/Symbols/symbolType11.ts Pass -tests/cases/conformance/es6/Symbols/symbolType12.ts Fail +tests/cases/conformance/es6/Symbols/symbolType12.ts Pass tests/cases/conformance/es6/Symbols/symbolType13.ts Fail tests/cases/conformance/es6/Symbols/symbolType14.ts Fail tests/cases/conformance/es6/Symbols/symbolType15.ts Fail @@ -157,11 +157,11 @@ tests/cases/conformance/es6/Symbols/symbolType19.ts Pass tests/cases/conformance/es6/Symbols/symbolType2.ts Fail tests/cases/conformance/es6/Symbols/symbolType20.ts Fail tests/cases/conformance/es6/Symbols/symbolType3.ts Fail -tests/cases/conformance/es6/Symbols/symbolType4.ts Fail -tests/cases/conformance/es6/Symbols/symbolType5.ts Fail -tests/cases/conformance/es6/Symbols/symbolType6.ts Fail -tests/cases/conformance/es6/Symbols/symbolType7.ts Fail -tests/cases/conformance/es6/Symbols/symbolType8.ts Fail +tests/cases/conformance/es6/Symbols/symbolType4.ts Pass +tests/cases/conformance/es6/Symbols/symbolType5.ts Pass +tests/cases/conformance/es6/Symbols/symbolType6.ts Pass +tests/cases/conformance/es6/Symbols/symbolType7.ts Pass +tests/cases/conformance/es6/Symbols/symbolType8.ts Pass tests/cases/conformance/es6/Symbols/symbolType9.ts Fail tests/cases/conformance/esnext/logicalAssignment/logicalAssignment11.ts Pass tests/cases/conformance/types/conditional/conditionalTypes1.ts Pass From 3288458be15faf79daeead607433241f03fc8d57 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sun, 5 Jul 2026 17:56:46 -0700 Subject: [PATCH 3/5] fix(types): widen optional parameters with undefined inside function bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `?`-optional parameter (`function f(x?: T)`) was bound inside the function/method/arrow BODY using its raw declared annotation type `T`, never `T | undefined` — even though the caller can genuinely omit it. `function f(x?: string) { x.toUpperCase() }` produced zero diagnostics, while the explicit-union equivalent `function f(x: string | undefined)` correctly flagged it. The signature type shown externally (`f?: T`, used for arity/display) was never wrong; only the in-body binding was. Root cause: BuildFunctionSignature (TypeChecker.cs) resolves each parameter's declared type once and reuses it both for the function's own callable signature and for defining the parameter in the checking scope. A defaulted parameter (`x: T = default`) is correctly NOT widened — the default substitutes a same-typed value before the body ever observes it, so it's genuinely never undefined there. Fix: new WidenOptionalParamsForBody helper, applied at every body-binding call site (top-level functions, arrow/function expressions and object method shorthand, and class methods in both the class-declaration and class-expression paths — a constructor is just a method named "constructor" so it's covered by the same fix). --- TypeSystem/TypeChecker.Expressions.cs | 11 +++++-- TypeSystem/TypeChecker.Statements.Classes.cs | 6 +++- .../TypeChecker.Statements.Functions.cs | 13 +++++--- TypeSystem/TypeChecker.cs | 31 +++++++++++++++++-- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/TypeSystem/TypeChecker.Expressions.cs b/TypeSystem/TypeChecker.Expressions.cs index 79c4e1a8..572d5e7e 100644 --- a/TypeSystem/TypeChecker.Expressions.cs +++ b/TypeSystem/TypeChecker.Expressions.cs @@ -1461,10 +1461,14 @@ private TypeInfo CheckArrowFunction(Expr.ArrowFunction arrow, TypeInfo? expected arrowEnv.MarkAsConst(arrow.Name.Lexeme); // Function name is read-only in strict mode } - // Define parameters (may shadow function name if same identifier) + // Define parameters (may shadow function name if same identifier). Body-scope binding widens + // a bare `?`-optional parameter with `| undefined` (the caller may omit it) — see + // WidenOptionalParamsForBody; paramTypes itself stays the declared type for the callable + // signature (funcType above). + var bodyParamTypes = WidenOptionalParamsForBody(paramTypes, arrow.Parameters); for (int i = 0; i < arrow.Parameters.Count; i++) { - arrowEnv.Define(arrow.Parameters[i].Name.Lexeme, paramTypes[i]); + arrowEnv.Define(arrow.Parameters[i].Name.Lexeme, bodyParamTypes[i]); } // Save and set context - function bodies are isolated from outer loop/switch/label context @@ -2189,8 +2193,9 @@ TypeInfo.Function BuildMethodFuncType(Stmt.Function method) _ => throw new TypeCheckException($" Unexpected method type for '{method.Name.Lexeme}'.") }; + var bodyParamTypes = WidenOptionalParamsForBody(methodType.ParamTypes, method.Parameters); for (int i = 0; i < method.Parameters.Count; i++) - methodEnv.Define(method.Parameters[i].Name.Lexeme, methodType.ParamTypes[i]); + methodEnv.Define(method.Parameters[i].Name.Lexeme, bodyParamTypes[i]); TypeEnvironment previousEnvFunc = _environment; TypeInfo? previousReturnFunc = _currentFunctionReturnType; diff --git a/TypeSystem/TypeChecker.Statements.Classes.cs b/TypeSystem/TypeChecker.Statements.Classes.cs index eaefe321..a8b58cd9 100644 --- a/TypeSystem/TypeChecker.Statements.Classes.cs +++ b/TypeSystem/TypeChecker.Statements.Classes.cs @@ -753,9 +753,13 @@ TypeInfo.Function BuildMethodFuncType(Stmt.Function method) _ => throw new TypeCheckException($" Unexpected method type for '{method.Name.Lexeme}'.") }; + // Body-scope binding widens a bare `?`-optional parameter with `| undefined` (the + // caller may omit it) — the method's own callable signature (methodType) keeps the + // declared type. + var bodyParamTypes = WidenOptionalParamsForBody(methodType.ParamTypes, method.Parameters); for (int i = 0; i < method.Parameters.Count; i++) { - methodEnv.Define(method.Parameters[i].Name.Lexeme, methodType.ParamTypes[i]); + methodEnv.Define(method.Parameters[i].Name.Lexeme, bodyParamTypes[i]); } // Save and set context - method bodies are isolated from outer loop/switch/label context diff --git a/TypeSystem/TypeChecker.Statements.Functions.cs b/TypeSystem/TypeChecker.Statements.Functions.cs index 153b50c1..67c26de3 100644 --- a/TypeSystem/TypeChecker.Statements.Functions.cs +++ b/TypeSystem/TypeChecker.Statements.Functions.cs @@ -651,10 +651,13 @@ private void CheckFunctionBodyAndInferReturn( // The enclosing environment, where the (possibly refined) function type is registered. TypeEnvironment previousEnv = _environment; - // Add parameters to function environment and check body + // Add parameters to function environment and check body. Body-scope binding widens a bare + // `?`-optional parameter with `| undefined` (the caller may omit it) — the function's own + // callable signature (paramTypes, used for thisFuncType et al.) keeps the declared type. + var bodyParamTypes = WidenOptionalParamsForBody(paramTypes, funcStmt.Parameters); for (int i = 0; i < funcStmt.Parameters.Count; i++) { - funcEnv.Define(funcStmt.Parameters[i].Name.Lexeme, paramTypes[i]); + funcEnv.Define(funcStmt.Parameters[i].Name.Lexeme, bodyParamTypes[i]); } // Save and set context - function bodies are isolated from outer loop/switch/label context @@ -696,11 +699,13 @@ private void CheckFunctionBodyAndInferReturn( _suppressDiagnostics++; } - // Push a new scope for declared variable types and record parameter types + // Push a new scope for declared variable types and record parameter types (widened, matching + // the environment binding above — so a later reassignment narrows/widens against the TRUE + // declared type, which for a bare-optional parameter includes undefined). PushDeclaredVariableScope(); for (int i = 0; i < funcStmt.Parameters.Count; i++) { - RecordDeclaredType(funcStmt.Parameters[i].Name.Lexeme, paramTypes[i]); + RecordDeclaredType(funcStmt.Parameters[i].Name.Lexeme, bodyParamTypes[i]); } // Enter escape analysis scope and register parameters as local variables diff --git a/TypeSystem/TypeChecker.cs b/TypeSystem/TypeChecker.cs index c2b747cc..723d880e 100644 --- a/TypeSystem/TypeChecker.cs +++ b/TypeSystem/TypeChecker.cs @@ -683,6 +683,10 @@ public EnvironmentScope(TypeChecker checker, TypeEnvironment newEnv) /// /// Builds a function signature by parsing parameters and validating optional/required ordering. + /// Returns the DECLARED annotation types (`paramTypes`) — used for the function's own callable + /// signature (`f?: T`, not `f: T | undefined`; matters for arity/display). Body-scope binding + /// uses a WIDENED type instead — see — since a caller + /// may genuinely omit a `?`-optional parameter with no default. /// /// Function/method parameters to parse /// Whether to type-check default parameter values @@ -701,7 +705,8 @@ public EnvironmentScope(TypeChecker checker, TypeEnvironment newEnv) // A default value may reference any PRECEDING parameter (`(x: T, y: U = x)`), so defaults // are checked in a scope where the earlier parameters are progressively defined. Each // parameter is defined AFTER its own default is checked, so self-reference still resolves - // to an outer binding or errors. + // to an outer binding or errors. A preceding bare-optional parameter is visible here with + // its WIDENED (possibly-undefined) type, matching what the function body itself would see. var paramScope = new TypeEnvironment(_environment); foreach (var param in parameters) @@ -747,13 +752,35 @@ public EnvironmentScope(TypeChecker checker, TypeEnvironment newEnv) requiredParams++; } - paramScope.Define(param.Name.Lexeme, paramType); + paramScope.Define(param.Name.Lexeme, + param.IsOptional && param.DefaultValue == null ? CreateUnion(paramType, new TypeInfo.Undefined()) : paramType); } bool hasRest = parameters.Any(p => p.IsRest); return (paramTypes, requiredParams, hasRest, paramNames); } + /// + /// Widens a function/method/arrow's declared parameter types for BODY-SCOPE binding: a bare + /// `?`-optional parameter with no default may genuinely be omitted by the caller, so the body + /// sees `T | undefined` even though the callable signature itself (paramTypes as returned + /// by ) keeps exactly `T`. A defaulted parameter is NOT + /// widened: the default substitutes a same-typed value before the body ever observes it, so + /// it's never actually undefined there. Rest parameters and required parameters pass through. + /// + private List WidenOptionalParamsForBody(List paramTypes, List parameters) + { + var result = new List(paramTypes.Count); + for (int i = 0; i < paramTypes.Count; i++) + { + var param = i < parameters.Count ? parameters[i] : null; + result.Add(param is { IsOptional: true, DefaultValue: null } + ? CreateUnion(paramTypes[i], new TypeInfo.Undefined()) + : paramTypes[i]); + } + return result; + } + /// /// Collapses a list of types into a single type or union. /// If the list has only one element, returns that element directly. From 493b318c54213fd89b81af07c05da4d2f39930f8 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sun, 5 Jul 2026 17:56:53 -0700 Subject: [PATCH 4/5] fix(types): reject calling a possibly null/undefined value (TS2721/2722/2723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-optional call (`f(42)`, not `f?.(42)`) on a callee whose type is a nullable union previously never errored: CheckCall's union-of-function- members branch explicitly added `undefined` to the result type instead of rejecting the call, on the theory that "calling undefined would fail at runtime, but for type checking we allow it" — which doesn't match tsc, which always rejects this. Adds the missing check right after the existing optional-call nullish- stripping logic: a non-optional call on a union containing null and/or undefined now throws TS2721 (null), TS2722 (undefined), or TS2723 (both), matching tsc's exact diagnostic selection. The now-unreachable "allow calling undefined" branch is removed as dead code — a non-optional call on a nullable union throws before reaching it, and an optional call has already stripped null/undefined by that point. --- TypeSystem/TypeChecker.Calls.cs | 40 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/TypeSystem/TypeChecker.Calls.cs b/TypeSystem/TypeChecker.Calls.cs index bdda1f2b..a76696b4 100644 --- a/TypeSystem/TypeChecker.Calls.cs +++ b/TypeSystem/TypeChecker.Calls.cs @@ -48,6 +48,16 @@ private TypeInfo CheckCall(Expr.Call call) calleeType = nonNullish.Count == 1 ? nonNullish[0] : new TypeInfo.Union(nonNullish); } + // Non-optional call on a genuinely nullable callee: tsc rejects this outright (TS2721/2722/ + // 2723) rather than silently allowing it — unlike the union-of-function-members branch further + // below (which permits reading a possibly-missing PROPERTY that happens to be a function), + // actually INVOKING a possibly-null/undefined value is always an error. + if (!call.Optional && calleeType is TypeInfo.Union nullableCallee + && (nullableCallee.ContainsNull || nullableCallee.ContainsUndefined)) + { + throw CannotInvokeNullishError(nullableCallee, call.Paren.Line); + } + if (calleeType is TypeInfo.Class classType) { return new TypeInfo.Instance(classType); @@ -299,18 +309,8 @@ private TypeInfo CheckCall(Expr.Call call) } } - // Also include undefined if the union has non-function members - // (e.g., the property might be missing on some types) - var nonFunctionMembers = unionCallee.FlattenedTypes - .Where(t => t is not TypeInfo.Function and not TypeInfo.OverloadedFunction and not TypeInfo.GenericFunction) - .ToList(); - if (nonFunctionMembers.Any(t => t is TypeInfo.Undefined)) - { - // Calling undefined would fail at runtime, but for type checking, - // we allow it to match TypeScript's behavior and add undefined to result - returnTypes.Add(new TypeInfo.Undefined()); - } - + // No undefined/null member can survive to here: a non-optional call on a nullable + // union already threw above, and an optional call already stripped them. var unique = returnTypes.Distinct(TypeInfoEqualityComparer.Instance).ToList(); return unique.Count == 1 ? unique[0] : new TypeInfo.Union(unique); } @@ -319,6 +319,22 @@ private TypeInfo CheckCall(Expr.Call call) throw new TypeCheckException($"Can only call functions.", tsCode: "TS2349"); } + /// + /// Builds the "cannot invoke" error for a non-optional call on a nullable union callee, + /// picking the same code tsc would (TS2721 null-only, TS2722 undefined-only, TS2723 both) — + /// unlike member access, calls have no separate bare-identifier code family. + /// + private static TypeCheckException CannotInvokeNullishError(TypeInfo.Union union, int line) + { + (string code, string subject) = (union.ContainsNull, union.ContainsUndefined) switch + { + (true, false) => ("TS2721", "'null'"), + (false, true) => ("TS2722", "'undefined'"), + _ => ("TS2723", "'null' or 'undefined'"), + }; + return new TypeCheckException($"Cannot invoke an object which is possibly {subject}.", line, tsCode: code); + } + /// /// Checks a call on an interface with call signatures. /// Returns the return type of the matching call signature. From 08d25f80364e422352e21b278971bc584c8d0e4f Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sun, 5 Jul 2026 17:57:05 -0700 Subject: [PATCH 5/5] fix(types): narrow away both null and undefined on loose equality checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `x == null` / `x != null` / `x == undefined` / `x != undefined` (loose equality) never narrowed at all — only strict `===`/`!==` and truthiness did. This is a long-standing, general narrowing gap, unrelated to any specific type; it was simply never exercised by an existing test because nothing produced a genuinely-nullable value through this exact idiom until the optional-parameter body-widening fix started doing so — stdlib/node/path.ts's `ext != null && ext.length > 0` (ext is a bare optional parameter) newly failed to type-check once `ext` correctly became `string | undefined` in the body, surfacing this pre-existing gap as a 285-test xUnit regression. Root cause: AnalyzeNullCheck and AnalyzePathNullCheck's `checkingForNull` flag only ever excluded ONE of null/undefined (whichever literal was compared), with no way to express "exclude both" — the callers pass the same flag regardless of whether the source comparison was loose or strict. JS's abstract equality treats null and undefined as mutually `==`-equal, so `x == null` must exclude BOTH from the union, unlike `x === null` which excludes only null. Both helpers gain a `checkBoth` parameter, threaded from the call sites in AnalyzeTypeGuard and AnalyzePathTypeGuard based on whether the operator is the loose (EQUAL_EQUAL/BANG_EQUAL) or strict (EQUAL_EQUAL_EQUAL/BANG_EQUAL_EQUAL) variant. AnalyzePathTypeGuard was also missing `undefined`-literal patterns entirely (only `null`-literal comparisons were recognized for path/property narrowing) — added to match the existing bare-variable patterns. --- .../TypeChecker.Compatibility.TypeGuards.cs | 145 ++++++++++++++---- 1 file changed, 113 insertions(+), 32 deletions(-) diff --git a/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs b/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs index e1ea4889..25903b0e 100644 --- a/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs +++ b/TypeSystem/TypeChecker.Compatibility.TypeGuards.cs @@ -45,13 +45,14 @@ bin3.Left is Expr.Variable v3 && return AnalyzeInstanceofGuard(v3.Name.Lexeme, classVar.Name); } - // Pattern 4: x === null or x == null + // Pattern 4: x === null or x == null. A LOOSE `==` against EITHER null or undefined narrows + // away BOTH (JS: `null == undefined`); only STRICT `===` narrows away just the one compared. if (condition is Expr.Binary bin4 && bin4.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && bin4.Left is Expr.Variable v4 && bin4.Right is Expr.Literal { Value: null }) { - return AnalyzeNullCheck(v4.Name.Lexeme, checkingForNull: true, negated: false); + return AnalyzeNullCheck(v4.Name.Lexeme, checkingForNull: true, negated: false, checkBoth: bin4.Operator.Type == TokenType.EQUAL_EQUAL); } // Pattern 4b: null === x (reversed) @@ -60,7 +61,7 @@ bin4b.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && bin4b.Right is Expr.Variable v4b && bin4b.Left is Expr.Literal { Value: null }) { - return AnalyzeNullCheck(v4b.Name.Lexeme, checkingForNull: true, negated: false); + return AnalyzeNullCheck(v4b.Name.Lexeme, checkingForNull: true, negated: false, checkBoth: bin4b.Operator.Type == TokenType.EQUAL_EQUAL); } // Pattern 5: x !== null or x != null @@ -69,7 +70,7 @@ bin5.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && bin5.Left is Expr.Variable v5 && bin5.Right is Expr.Literal { Value: null }) { - return AnalyzeNullCheck(v5.Name.Lexeme, checkingForNull: true, negated: true); + return AnalyzeNullCheck(v5.Name.Lexeme, checkingForNull: true, negated: true, checkBoth: bin5.Operator.Type == TokenType.BANG_EQUAL); } // Note: Property null checks (x.prop !== null) are handled by AnalyzePropertyTypeGuard @@ -81,7 +82,7 @@ bin6.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && bin6.Left is Expr.Variable v6 && bin6.Right is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) { - return AnalyzeNullCheck(v6.Name.Lexeme, checkingForNull: false, negated: false); + return AnalyzeNullCheck(v6.Name.Lexeme, checkingForNull: false, negated: false, checkBoth: bin6.Operator.Type == TokenType.EQUAL_EQUAL); } // Pattern 6b: undefined === x (reversed) @@ -90,7 +91,7 @@ bin6b.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && bin6b.Right is Expr.Variable v6b && bin6b.Left is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) { - return AnalyzeNullCheck(v6b.Name.Lexeme, checkingForNull: false, negated: false); + return AnalyzeNullCheck(v6b.Name.Lexeme, checkingForNull: false, negated: false, checkBoth: bin6b.Operator.Type == TokenType.EQUAL_EQUAL); } // Pattern 7: x !== undefined @@ -99,7 +100,7 @@ bin7.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && bin7.Left is Expr.Variable v7 && bin7.Right is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) { - return AnalyzeNullCheck(v7.Name.Lexeme, checkingForNull: false, negated: true); + return AnalyzeNullCheck(v7.Name.Lexeme, checkingForNull: false, negated: true, checkBoth: bin7.Operator.Type == TokenType.BANG_EQUAL); } // Pattern 7b: x === / x !== (string/number/boolean literal equality). @@ -479,20 +480,28 @@ private bool IsInstanceOf(TypeInfo type, TypeInfo classType) /// Analyzes null/undefined equality checks like `x === null` or `x !== undefined`. /// private (string? VarName, TypeInfo? NarrowedType, TypeInfo? ExcludedType) AnalyzeNullCheck( - string varName, bool checkingForNull, bool negated) + string varName, bool checkingForNull, bool negated, bool checkBoth = false) { var currentType = _environment.Get(varName); if (currentType == null) return (null, null, null); - TypeInfo nullishType = checkingForNull ? new TypeInfo.Null() : new TypeInfo.Undefined(); + // checkBoth: a LOOSE comparison (`== null`/`!= null`/`== undefined`/`!= undefined`) against + // EITHER literal narrows away BOTH null and undefined — JS's abstract equality treats them + // as mutually `==`-equal (`null == undefined` is true) — unlike a STRICT `===`/`!==` + // comparison, which narrows away only the specific one compared. + bool IsNullish(TypeInfo t) => checkBoth + ? t is TypeInfo.Null or TypeInfo.Undefined + : checkingForNull ? t is TypeInfo.Null : t is TypeInfo.Undefined; + + TypeInfo nullishType = checkBoth + ? new TypeInfo.Union([new TypeInfo.Null(), new TypeInfo.Undefined()]) + : checkingForNull ? new TypeInfo.Null() : new TypeInfo.Undefined(); if (currentType is TypeInfo.Union union) { var flattenedTypes = union.FlattenedTypes; - var nonNullish = flattenedTypes.Where(t => - checkingForNull ? t is not TypeInfo.Null : t is not TypeInfo.Undefined).ToList(); - var nullish = flattenedTypes.Where(t => - checkingForNull ? t is TypeInfo.Null : t is TypeInfo.Undefined).ToList(); + var nonNullish = flattenedTypes.Where(t => !IsNullish(t)).ToList(); + var nullish = flattenedTypes.Where(IsNullish).ToList(); TypeInfo? nonNullishType = nonNullish.Count == 0 ? new TypeInfo.Never() : nonNullish.Count == 1 ? nonNullish[0] : new TypeInfo.Union(nonNullish); @@ -507,13 +516,13 @@ private bool IsInstanceOf(TypeInfo type, TypeInfo classType) } // Non-union type: if it's nullable, we can still narrow - if (currentType is TypeInfo.Null && checkingForNull) + if (currentType is TypeInfo.Null && (checkingForNull || checkBoth)) { if (negated) return (varName, new TypeInfo.Never(), currentType); return (varName, currentType, new TypeInfo.Never()); } - if (currentType is TypeInfo.Undefined && !checkingForNull) + if (currentType is TypeInfo.Undefined && (!checkingForNull || checkBoth)) { if (negated) return (varName, new TypeInfo.Never(), currentType); @@ -720,7 +729,8 @@ bin4.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && /// private (Narrowing.NarrowingPath? Path, TypeInfo? NarrowedType, TypeInfo? ExcludedType) AnalyzePathTypeGuard(Expr condition) { - // Pattern: path !== null or path != null + // Pattern: path !== null or path != null. A LOOSE `!=` against EITHER null or undefined + // narrows away BOTH (JS: `null == undefined`); STRICT `!==` narrows away just the one compared. if (condition is Expr.Binary bin && bin.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && bin.Right is Expr.Literal { Value: null }) @@ -728,7 +738,7 @@ bin.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && var path = Narrowing.NarrowingPathExtractor.TryExtract(bin.Left); if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) { - return AnalyzePathNullCheck(path, bin.Left, negated: true); + return AnalyzePathNullCheck(path, bin.Left, negated: true, checkingForNull: true, checkBoth: bin.Operator.Type == TokenType.BANG_EQUAL); } } @@ -740,7 +750,7 @@ bin2.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && var path = Narrowing.NarrowingPathExtractor.TryExtract(bin2.Right); if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) { - return AnalyzePathNullCheck(path, bin2.Right, negated: true); + return AnalyzePathNullCheck(path, bin2.Right, negated: true, checkingForNull: true, checkBoth: bin2.Operator.Type == TokenType.BANG_EQUAL); } } @@ -752,7 +762,7 @@ bin3.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && var path = Narrowing.NarrowingPathExtractor.TryExtract(bin3.Left); if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) { - return AnalyzePathNullCheck(path, bin3.Left, negated: false); + return AnalyzePathNullCheck(path, bin3.Left, negated: false, checkingForNull: true, checkBoth: bin3.Operator.Type == TokenType.EQUAL_EQUAL); } } @@ -764,7 +774,55 @@ bin4.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && var path = Narrowing.NarrowingPathExtractor.TryExtract(bin4.Right); if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) { - return AnalyzePathNullCheck(path, bin4.Right, negated: false); + return AnalyzePathNullCheck(path, bin4.Right, negated: false, checkingForNull: true, checkBoth: bin4.Operator.Type == TokenType.EQUAL_EQUAL); + } + } + + // Pattern: path !== undefined or path != undefined + if (condition is Expr.Binary bin5 && + bin5.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && + bin5.Right is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) + { + var path = Narrowing.NarrowingPathExtractor.TryExtract(bin5.Left); + if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) + { + return AnalyzePathNullCheck(path, bin5.Left, negated: true, checkingForNull: false, checkBoth: bin5.Operator.Type == TokenType.BANG_EQUAL); + } + } + + // Pattern: undefined !== path (reversed) + if (condition is Expr.Binary bin6 && + bin6.Operator.Type is TokenType.BANG_EQUAL or TokenType.BANG_EQUAL_EQUAL && + bin6.Left is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) + { + var path = Narrowing.NarrowingPathExtractor.TryExtract(bin6.Right); + if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) + { + return AnalyzePathNullCheck(path, bin6.Right, negated: true, checkingForNull: false, checkBoth: bin6.Operator.Type == TokenType.BANG_EQUAL); + } + } + + // Pattern: path === undefined + if (condition is Expr.Binary bin7 && + bin7.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && + bin7.Right is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) + { + var path = Narrowing.NarrowingPathExtractor.TryExtract(bin7.Left); + if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) + { + return AnalyzePathNullCheck(path, bin7.Left, negated: false, checkingForNull: false, checkBoth: bin7.Operator.Type == TokenType.EQUAL_EQUAL); + } + } + + // Pattern: undefined === path (reversed) + if (condition is Expr.Binary bin8 && + bin8.Operator.Type is TokenType.EQUAL_EQUAL or TokenType.EQUAL_EQUAL_EQUAL && + bin8.Left is Expr.Literal { Value: Runtime.Types.SharpTSUndefined }) + { + var path = Narrowing.NarrowingPathExtractor.TryExtract(bin8.Right); + if (path != null && Narrowing.NarrowingPathExtractor.IsWithinDepthLimit(path)) + { + return AnalyzePathNullCheck(path, bin8.Right, negated: false, checkingForNull: false, checkBoth: bin8.Operator.Type == TokenType.EQUAL_EQUAL); } } @@ -1098,34 +1156,57 @@ void Walk(Expr expr) /// Analyzes a null check for a narrowable path. /// private (Narrowing.NarrowingPath? Path, TypeInfo? NarrowedType, TypeInfo? ExcludedType) AnalyzePathNullCheck( - Narrowing.NarrowingPath path, Expr expr, bool negated) + Narrowing.NarrowingPath path, Expr expr, bool negated, bool checkingForNull = true, bool checkBoth = false) { // Get the current type of the expression TypeInfo currentType = CheckExpr(expr); - // If it's a union containing null, we can narrow + // checkBoth: a LOOSE comparison (`== null`/`!= null`/`== undefined`/`!= undefined`) against + // EITHER literal narrows away BOTH null and undefined (JS: `null == undefined`) — unlike a + // STRICT `===`/`!==` comparison, which narrows away only the specific one compared. + bool IsNullish(TypeInfo t) => checkBoth + ? t is TypeInfo.Null or TypeInfo.Undefined + : checkingForNull ? t is TypeInfo.Null : t is TypeInfo.Undefined; + + // If it's a union containing the checked nullish type(s), we can narrow if (currentType is TypeInfo.Union union) { var flattenedTypes = union.FlattenedTypes; - var nonNull = flattenedTypes.Where(t => t is not TypeInfo.Null).ToList(); - var nullTypes = flattenedTypes.Where(t => t is TypeInfo.Null).ToList(); + var nonNullish = flattenedTypes.Where(t => !IsNullish(t)).ToList(); + var nullishTypes = flattenedTypes.Where(IsNullish).ToList(); - if (nonNull.Count == 0 && nullTypes.Count == 0) + if (nonNullish.Count == 0 && nullishTypes.Count == 0) return (null, null, null); - TypeInfo nonNullType = nonNull.Count == 0 ? new TypeInfo.Never() : - nonNull.Count == 1 ? nonNull[0] : new TypeInfo.Union(nonNull); - TypeInfo nullType = nullTypes.Count == 0 ? new TypeInfo.Null() : - nullTypes.Count == 1 ? nullTypes[0] : new TypeInfo.Union(nullTypes); + TypeInfo nonNullishType = nonNullish.Count == 0 ? new TypeInfo.Never() : + nonNullish.Count == 1 ? nonNullish[0] : new TypeInfo.Union(nonNullish); + TypeInfo nullishType = nullishTypes.Count == 0 + ? (checkBoth ? new TypeInfo.Union([new TypeInfo.Null(), new TypeInfo.Undefined()]) + : checkingForNull ? new TypeInfo.Null() : new TypeInfo.Undefined()) + : nullishTypes.Count == 1 ? nullishTypes[0] : new TypeInfo.Union(nullishTypes); // if (path !== null) -> then: non-null, else: null // if (path === null) -> then: null, else: non-null if (negated) - return (path, nonNullType, nullType); - return (path, nullType, nonNullType); + return (path, nonNullishType, nullishType); + return (path, nullishType, nonNullishType); + } + + // Non-union type: if it's nullable, we can still narrow + if (currentType is TypeInfo.Null && (checkingForNull || checkBoth)) + { + if (negated) + return (path, new TypeInfo.Never(), currentType); + return (path, currentType, new TypeInfo.Never()); + } + if (currentType is TypeInfo.Undefined && (!checkingForNull || checkBoth)) + { + if (negated) + return (path, new TypeInfo.Never(), currentType); + return (path, currentType, new TypeInfo.Never()); } - // Not a union with null - no narrowing effect + // Not nullable in the checked sense - no narrowing effect return (null, null, null); }