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 diff --git a/TypeSystem/TypeChecker.Calls.cs b/TypeSystem/TypeChecker.Calls.cs index 33f2c3d2..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. @@ -365,6 +381,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.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); } 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.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; } 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.