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.