diff --git a/Execution/Interpreter.Statements.cs b/Execution/Interpreter.Statements.cs
index 76d235a4..207d861c 100644
--- a/Execution/Interpreter.Statements.cs
+++ b/Execution/Interpreter.Statements.cs
@@ -961,20 +961,31 @@ private ExecutionResult ExecuteForIn(Stmt.ForIn forIn)
///
/// Normalizes an array binding-pattern source through the iterator protocol (#685). Array
/// destructuring (const [a, b] = src) desugars to positional index access, which is only
- /// correct for index-addressable sources. Arrays, typed arrays and buffers pass through unchanged
- /// (fast path); any other source — including strings — is materialized into a
- /// via (the same routine spread uses)
- /// so the index access reads the iterated elements. Strings are intentionally materialized (not on
- /// the fast path) so a rest element binds a fresh array of characters rather than the trailing
- /// substring (const [a, ...rest] = "hi" → rest = ["i"]), matching ECMA-262 (#753);
- /// non-rest character values are identical either way. A genuinely non-iterable source throws "is
- /// not iterable", matching JS; the type checker already rejects those statically except behind
- /// any.
+ /// correct for index-addressable sources. Plain arrays pass through unchanged (fast path). Typed
+ /// arrays and buffers are index-addressable but NOT iterable via ,
+ /// so they are materialized element-by-element into a fresh ; any other
+ /// source — including strings — is materialized via (the
+ /// same routine spread uses) so the index access reads the iterated elements. Strings (#753) and
+ /// typed arrays/buffers (#781) are intentionally materialized (not on the fast path) so a rest
+ /// element binds a fresh Array rather than the trailing substring / typed-array slice
+ /// (const [a, ...rest] = "hi" → rest = ["i"]; const [a, ...rest] = u8 →
+ /// rest is a real Array), matching ECMA-262; non-rest element values are identical either
+ /// way. A genuinely non-iterable source throws "is not iterable", matching JS; the type checker
+ /// already rejects those statically except behind any.
///
internal object? NormalizeArrayDestructureSource(object? value)
{
- if (value is SharpTSArray or SharpTSTypedArray or SharpTSBuffer)
- return value;
+ switch (value)
+ {
+ case SharpTSArray:
+ return value;
+ // Typed arrays / buffers expose index access but no [Symbol.iterator] in this runtime, so
+ // GetIterableElements would throw; read their elements directly into a fresh Array (#781).
+ case SharpTSTypedArray typed:
+ return typed.ToArray();
+ case SharpTSBuffer buffer:
+ return new SharpTSArray(buffer.Data.Select(b => (object?)(double)b).ToList());
+ }
return new SharpTSArray(GetIterableElements(value).ToList());
}
diff --git a/Parsing/AST.cs b/Parsing/AST.cs
index 8b94edb5..38817e18 100644
--- a/Parsing/AST.cs
+++ b/Parsing/AST.cs
@@ -111,8 +111,18 @@ public record Comma(Expr Left, Expr Right) : Expr;
/// Assignments, then yield ResultValue. Assignments contains only synthesized
/// and statements (no control flow), so it is
/// safe in any expression position.
- ///
- public record DestructuringAssign(List Assignments, Expr ResultValue) : Expr;
+ /// / retain the un-lowered pattern and
+ /// default RHS so that when this node was eager-parsed as a nested element WITH a default
+ /// ([[a] = []], {p: {x} = {}}) the outer pattern walk can re-lower the inner pattern
+ /// against the defaulted access instead of the pre-built (wrong-source) statements (#779). Both are
+ /// null for a top-level assignment-destructuring, where only Assignments/ResultValue are
+ /// used; backends never read them.
+ ///
+ public record DestructuringAssign(
+ List Assignments,
+ Expr ResultValue,
+ Expr? RawTarget = null,
+ Expr? RawDefault = null) : Expr;
public record Binary(Expr Left, Token Operator, Expr Right) : Expr;
public record Logical(Expr Left, Token Operator, Expr Right) : Expr;
public record NullishCoalescing(Expr Left, Expr Right) : Expr;
@@ -189,12 +199,17 @@ public enum ObjectPropertyKind { Value, Getter, Setter, Method }
/// Whether this is a spread property (...obj)
/// The kind of property (value, getter, setter, method)
/// The setter parameter (for Kind=Setter only)
+ /// True for the cover-grammar form { a = 5 } (an ES
+ /// CoverInitializedName). The value is stored as Expr.Assign(a, 5) so it round-trips to the
+ /// #754 assignment-destructuring lowering; this flag distinguishes it from the legal expression
+ /// { a: a = 5 } so a pure-expression object literal can be rejected as tsc does (#780).
public record Property(
PropertyKey? Key,
Expr Value,
bool IsSpread = false,
ObjectPropertyKind Kind = ObjectPropertyKind.Value,
- Stmt.Parameter? SetterParam = null);
+ Stmt.Parameter? SetterParam = null,
+ bool IsShorthandDefault = false);
public record GetIndex(Expr Object, Expr Index, bool Optional = false) : Expr;
public record SetIndex(Expr Object, Expr Index, Expr Value) : Expr;
public record Super(Token Keyword, Token? Method) : Expr; // Method is null for super() constructor calls
diff --git a/Parsing/Parser.Destructuring.cs b/Parsing/Parser.Destructuring.cs
index ca7679e1..15c10b17 100644
--- a/Parsing/Parser.Destructuring.cs
+++ b/Parsing/Parser.Destructuring.cs
@@ -190,10 +190,10 @@ [new Expr.Literal((double)index)]
if (element.Pattern is IdentifierPattern id)
{
- // Apply default value if present
+ // Apply default value if present (undefined-only, #784)
if (id.DefaultValue != null)
{
- accessExpr = new Expr.NullishCoalescing(accessExpr, id.DefaultValue);
+ accessExpr = DefaultIfUndefined(accessExpr, id.DefaultValue, statements, pattern.Line);
}
statements.Add(new Stmt.Var(id.Name, null, accessExpr));
}
@@ -247,11 +247,11 @@ [new Expr.Variable(temp), excludeKeysExpr]
if (prop.Value is IdentifierPattern id)
{
- // Apply default value if present
+ // Apply default value if present (undefined-only, #784)
Expr? defaultVal = prop.DefaultValue ?? id.DefaultValue;
if (defaultVal != null)
{
- accessExpr = new Expr.NullishCoalescing(accessExpr, defaultVal);
+ accessExpr = DefaultIfUndefined(accessExpr, defaultVal, statements, pattern.Line);
}
statements.Add(new Stmt.Var(id.Name, null, accessExpr));
}
@@ -290,7 +290,9 @@ private Expr BuildDestructuringAssignment(Expr pattern, Expr value, int line)
Token rhsTemp = GenerateTempVar(line);
stmts.Add(new Stmt.Var(rhsTemp, null, value));
LowerAssignmentTarget(pattern, new Expr.Variable(rhsTemp), stmts, line);
- return new Expr.DestructuringAssign(stmts, new Expr.Variable(rhsTemp));
+ // Retain the raw (pattern, default) so this node can be re-lowered if it turns out to be a nested
+ // element WITH a default (`[[a] = []]`) — see LowerElementWithDefault (#779).
+ return new Expr.DestructuringAssign(stmts, new Expr.Variable(rhsTemp), RawTarget: pattern, RawDefault: value);
}
private void LowerAssignmentTarget(Expr target, Expr source, List stmts, int line)
@@ -352,38 +354,57 @@ private void LowerObjectAssignmentTarget(Expr.ObjectLiteral obj, Expr source, Li
// Lowers one pattern element / property value to an assignment, peeling off a default (`= expr`)
// that the eager parser captured as an Assign (variable target) or Set/SetIndex/SetPrivate (member
- // target). `?? default` applies the default when the read is null/undefined (matching the existing
- // declaration desugaring; ECMA-262 is undefined-only — tracked separately).
+ // target). The default is applied only when the read is `undefined` (ECMA-262 AssignmentElement),
+ // via the shared DefaultIfUndefined helper (#784).
private void LowerElementWithDefault(Expr element, Expr access, List stmts, int line)
{
switch (Unwrap(element))
{
case Expr.Assign def:
LowerAssignmentTarget(new Expr.Variable(def.Name),
- new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ DefaultIfUndefined(access, def.Value, stmts, line), stmts, line);
break;
case Expr.Set def:
LowerAssignmentTarget(new Expr.Get(def.Object, def.Name),
- new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ DefaultIfUndefined(access, def.Value, stmts, line), stmts, line);
break;
case Expr.SetIndex def:
LowerAssignmentTarget(new Expr.GetIndex(def.Object, def.Index),
- new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ DefaultIfUndefined(access, def.Value, stmts, line), stmts, line);
break;
case Expr.SetPrivate def:
LowerAssignmentTarget(new Expr.GetPrivate(def.Object, def.Name),
- new Expr.NullishCoalescing(access, def.Value), stmts, line);
+ DefaultIfUndefined(access, def.Value, stmts, line), stmts, line);
+ break;
+ case Expr.DestructuringAssign { RawTarget: { } rawTarget, RawDefault: { } rawDefault }:
+ // A nested pattern WITH a default (`[[a] = []]`, `{p: {x} = {}}`): the eager parser lowered
+ // the inner `[a] = []` to a DestructuringAssign but kept its raw (target, default), so
+ // re-lower the inner pattern against the defaulted access — discarding the inner node's
+ // pre-built statements, which bound against the wrong (inner-rhs) source (#779).
+ LowerAssignmentTarget(rawTarget, DefaultIfUndefined(access, rawDefault, stmts, line), stmts, line);
break;
- case Expr.DestructuringAssign:
- // A nested pattern WITH a default (`[[a] = []]`, `{p: {x} = {}}`) was eagerly lowered,
- // losing its raw target; recovering it needs cover-grammar reparsing. Rare — see #779.
- throw new Exception("Nested destructuring with a default is not supported in assignment destructuring.");
default:
LowerAssignmentTarget(element, access, stmts, line);
break;
}
}
+ // Destructuring defaults are applied ONLY when the matched value is `undefined`, never `null`
+ // (ECMA-262 ArrayBindingPattern / ObjectBindingPattern / AssignmentElement) — unlike `??`, which
+ // also replaces null. The access is spilled into a temp so it is evaluated exactly once (a property
+ // read may invoke a getter) and the ternary's repeated operand is a side-effect-free temp read.
+ // Shared by both the declaration desugaring and the #754 assignment-destructuring lowering. #784
+ private Expr DefaultIfUndefined(Expr access, Expr defaultValue, List stmts, int line)
+ {
+ Token valueTemp = GenerateTempVar(line);
+ stmts.Add(new Stmt.Var(valueTemp, null, access));
+ Expr isUndefined = new Expr.Binary(
+ new Expr.Variable(valueTemp),
+ new Token(TokenType.EQUAL_EQUAL_EQUAL, "===", null, line),
+ new Expr.Literal(SharpTS.Runtime.Types.SharpTSUndefined.Instance));
+ return new Expr.Ternary(isUndefined, defaultValue, new Expr.Variable(valueTemp));
+ }
+
private static Expr MakeAssignmentExpr(Expr target, Expr value) => Unwrap(target) switch
{
Expr.Variable v => new Expr.Assign(v.Name, value),
diff --git a/Parsing/Parser.Expressions.cs b/Parsing/Parser.Expressions.cs
index 5466e991..4c3b3be3 100644
--- a/Parsing/Parser.Expressions.cs
+++ b/Parsing/Parser.Expressions.cs
@@ -954,6 +954,7 @@ private Expr Primary()
// Parse regular identifier (including 'get' and 'set' as property names)
Token name = ConsumePropertyName("Expect property name.");
Expr value;
+ bool isShorthandDefault = false;
if (Check(TokenType.LEFT_PAREN))
{
@@ -965,13 +966,23 @@ private Expr Primary()
// Explicit property: { x: value }
value = Expression();
}
+ else if (Match(TokenType.EQUAL))
+ {
+ // Cover-grammar shorthand-with-default: `{ x = 5 }` (an ES CoverInitializedName).
+ // Only valid as an object DESTRUCTURING pattern; stored as `{ x: (x = 5) }` so the
+ // #754 assignment-destructuring lowering recovers the `(target, default)`. A
+ // pure-expression `{ x = 5 }` is rejected in CheckObject via IsShorthandDefault (#780).
+ value = new Expr.Assign(name, Expression());
+ isShorthandDefault = true;
+ }
else
{
// Shorthand property: { x } -> { x: x }
value = new Expr.Variable(name);
}
- properties.Add(new Expr.Property(new Expr.IdentifierKey(name), value));
+ properties.Add(new Expr.Property(new Expr.IdentifierKey(name), value,
+ IsShorthandDefault: isShorthandDefault));
} while (Match(TokenType.COMMA));
}
Consume(TokenType.RIGHT_BRACE, "Expect '}' after object literal.");
diff --git a/SharpTS.Tests/SharedTests/DestructuringTests.cs b/SharpTS.Tests/SharedTests/DestructuringTests.cs
index 0b087556..7bfa1110 100644
--- a/SharpTS.Tests/SharedTests/DestructuringTests.cs
+++ b/SharpTS.Tests/SharedTests/DestructuringTests.cs
@@ -1,4 +1,5 @@
using SharpTS.Tests.Infrastructure;
+using SharpTS.TypeSystem.Exceptions;
using Xunit;
namespace SharpTS.Tests.SharedTests;
@@ -483,4 +484,164 @@ public void AssignmentDestructuring_ExpressionPositionAndChained(ExecutionMode m
}
#endregion
+
+ #region Undefined-only Defaults (#784)
+
+ // #784: a destructuring default applies ONLY when the matched value is `undefined`, never `null`
+ // (ECMA-262), unlike `??`. Declaration form, array and object patterns.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void Defaults_AppliedForUndefinedNotNull_Declaration(ExecutionMode mode)
+ {
+ var source = """
+ const [a = 9] = [null];
+ const [b = 9] = [undefined];
+ const o: { x?: number | null } = { x: null };
+ const { x = 9 } = o;
+ console.log(String(a), String(b), String(x));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("null 9 null\n", output);
+ }
+
+ // #784: same undefined-only rule for assignment destructuring (#754 form), AND a value-type default
+ // over an ABSENT element must yield the default — not NaN. The lowered spill temp lives inside an
+ // expression, so an unboxed numeric slot would coerce the runtime `undefined` sentinel (compiled).
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void Defaults_AssignmentDestructuring_UndefinedOnlyAndValueType(ExecutionMode mode)
+ {
+ var source = """
+ let a; [a = 5] = [null];
+ let b; [b = 5] = [];
+ const arr: number[] = [];
+ let c; [c = 5] = arr;
+ const o: { z?: number } = {};
+ let d; ({ z: d = 5 } = o);
+ console.log(String(a), String(b), String(c), String(d));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("null 5 5 5\n", output);
+ }
+
+ // #784: the default expression is evaluated lazily (only when undefined) and the matched value is
+ // read exactly once (a getter is not invoked twice).
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void Defaults_LazyAndSingleEvaluation(ExecutionMode mode)
+ {
+ var source = """
+ let ran = 0;
+ const side = () => { ran++; return 99; };
+ const [p = side()] = [7];
+ console.log(p, ran);
+ const [q = side()] = [undefined];
+ console.log(q, ran);
+ let reads = 0;
+ const src = { get k() { reads++; return undefined; } };
+ const { k = 42 } = src;
+ console.log(k, reads);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ // p: default not evaluated (value present); q: default evaluated exactly once; k: getter read once.
+ Assert.Equal("7 0\n99 1\n42 1\n", output);
+ }
+
+ #endregion
+
+ #region Typed-array Rest (#781)
+
+ // #781: a rest element over a typed-array source must collect a fresh Array (Array.isArray === true),
+ // not a typed-array slice. Interpreter-only: compiled `new Uint8Array([...])` is blocked by #782.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.InterpretedOnly), MemberType = typeof(ExecutionModes))]
+ public void ArrayDestructuring_TypedArrayRest_BindsFreshArray(ExecutionMode mode)
+ {
+ var source = """
+ const [a, ...rest] = new Uint8Array([1, 2, 3]);
+ console.log(a);
+ console.log(Array.isArray(rest));
+ console.log(rest.length);
+ console.log(rest[0], rest[1]);
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("1\ntrue\n2\n2 3\n", output);
+ }
+
+ #endregion
+
+ #region Object Shorthand-with-default Assignment Destructuring (#780)
+
+ // #780: `({ a = 5 } = src)` — the object-pattern cover-grammar form is now accepted by the parser and
+ // routes through the #754 assignment-destructuring lowering (undefined-only default, #784).
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_ObjectShorthandDefault(ExecutionMode mode)
+ {
+ var source = """
+ const present: { a?: number } = { a: 10 };
+ let a; ({ a = 5 } = present);
+ const missing: { b?: number } = {};
+ let b; ({ b = 5 } = missing);
+ console.log(String(a), String(b));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("10 5\n", output);
+ }
+
+ // #780: the cover-grammar `{ a = 5 }` is only valid as a destructuring pattern; using it as a plain
+ // object-literal EXPRESSION remains a semantic error, as in tsc.
+ [Fact]
+ public void ObjectLiteral_ShorthandDefault_AsExpression_IsTypeError()
+ {
+ var source = """
+ let a = 1;
+ const o = { a = 5 };
+ console.log(o);
+ """;
+
+ Assert.ThrowsAny(() => TestHarness.RunInterpreted(source));
+ }
+
+ #endregion
+
+ #region Nested Pattern with Default in Assignment Destructuring (#779)
+
+ // #779: a nested array/object pattern that itself carries a default in an ASSIGNMENT destructuring.
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_NestedArrayPatternWithDefault(ExecutionMode mode)
+ {
+ var source = """
+ let a; [[a] = [9]] = [[1]];
+ let b; [[b] = [9]] = [];
+ console.log(String(a), String(b));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("1 9\n", output);
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AssignmentDestructuring_NestedObjectPatternWithDefault(ExecutionMode mode)
+ {
+ var source = """
+ const src: { p?: { x: number } } = { p: { x: 1 } };
+ let x; ({ p: { x: x } = { x: 7 } } = src);
+ const empty: { p?: { x: number } } = {};
+ let y; ({ p: { x: y } = { x: 7 } } = empty);
+ console.log(String(x), String(y));
+ """;
+
+ var output = TestHarness.Run(source, mode);
+ Assert.Equal("1 7\n", output);
+ }
+
+ #endregion
}
diff --git a/TypeSystem/TypeChecker.Expressions.cs b/TypeSystem/TypeChecker.Expressions.cs
index aad760c1..af0a4e0a 100644
--- a/TypeSystem/TypeChecker.Expressions.cs
+++ b/TypeSystem/TypeChecker.Expressions.cs
@@ -151,6 +151,22 @@ internal TypeInfo VisitDestructuringAssign(Expr.DestructuringAssign expr)
{
foreach (var stmt in expr.Assignments)
CheckStmt(stmt);
+
+ // The lowered spill temps live inside an EXPRESSION, so they escape the whole-body
+ // numeric-slot taint pass (MarkUndefinedReachableNumericSlots) that the declaration
+ // desugaring's Stmt.Sequence goes through. A defaulted element/property whose source value
+ // is absent (`[a = 5] = arr`, `({c = 5} = obj)`) leaves the spill holding the runtime
+ // `undefined` sentinel; an unboxed `double` slot would coerce it to NaN at the store. Widen
+ // every synthesized temp back to an object slot (a no-op for the non-numeric source temps). #784
+ foreach (var stmt in expr.Assignments)
+ {
+ if (stmt is Stmt.Var { Initializer: { } init } varStmt)
+ {
+ _typeMap.MarkUndefinedReachableNumericLocal(varStmt);
+ _typeMap.MarkUndefinedReachableNumericLocal(init);
+ }
+ }
+
return CheckExpr(expr.ResultValue);
}
@@ -686,6 +702,16 @@ private TypeInfo CheckObject(Expr.ObjectLiteral obj)
}
else
{
+ if (prop.IsShorthandDefault)
+ {
+ // A `{ a = 5 }` CoverInitializedName reaching CheckObject was used as a plain object
+ // literal expression, not a destructuring target (assignment-destructuring patterns are
+ // consumed by BuildDestructuringAssignment before type-checking). tsc rejects it (#780).
+ throw new TypeCheckException(
+ " '=' can only be used in an object literal property inside a destructuring assignment.",
+ tsCode: "TS1312");
+ }
+
TypeInfo valueType = CheckExpr(prop.Value);
switch (prop.Key)