mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Destructure options ?? {} over records with required members
`const { a, ...rest } = options ?? {}` destructures the options record or
an empty object. Where the record had a required member, the `{}`
fallback was coerced into the record and failed with SC2002. The source
now lowers to the union of the record and the empty record, so the
union pattern lowering reads each key from the arm that holds it, the
empty arm answers undefined and the rest element packs nothing. A
record arm without a literal key now answers undefined in unionKeyGet
too, which the frontend already asked for and the validator rejected
as an internal error. All-optional options keep their lowering.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e86800de67
commit
5a6f2c0902
@@ -547,6 +547,19 @@ export function emitDynamicExpr(
|
||||
B.br(join);
|
||||
return;
|
||||
}
|
||||
// A declared-only arm without the literal key: the object has no
|
||||
// such own property, so the read answers undefined.
|
||||
if (literal !== null && !shape.indexValue) {
|
||||
const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1;
|
||||
if (tag < 0 || e.type.kind !== "union") {
|
||||
throw new InternalCompilerError(
|
||||
"llvm emitter bug: unionKeyGet missing key without an undefined result arm",
|
||||
);
|
||||
}
|
||||
B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`);
|
||||
B.br(join);
|
||||
return;
|
||||
}
|
||||
// Index-signature arm (or declared-only shape under a runtime
|
||||
// key): the shared keyed-read chain — a literal key naming no
|
||||
// declared field touches only the overflow map.
|
||||
|
||||
@@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js";
|
||||
import { lowerEventObjectPropertyWrite } from "./lower-event-target.js";
|
||||
import { lowerDynObjectLiteral } from "./expressions/object-literals.js";
|
||||
import { importMetaBindingSource } from "./import-meta.js";
|
||||
import { nullishEmptyPatternSource } from "./nullish-empty-pattern.js";
|
||||
import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js";
|
||||
import { notIterableSpelling } from "./iterable-spelling.js";
|
||||
import { isPrivateSplitConsumer } from "./split-consumers.js";
|
||||
@@ -2485,6 +2486,7 @@ export function lowerDestructuringDecl(
|
||||
}
|
||||
let init =
|
||||
importMetaBindingSource(lowerer, decl.name, decl.initializer) ??
|
||||
nullishEmptyPatternSource(lowerer, decl.name, decl.initializer) ??
|
||||
lowerer.lowerExpr(decl.initializer);
|
||||
const parseArgsDynObject =
|
||||
init.type.kind === "dyn" &&
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { BOOL, type IrExpr, type IrType, isUnitType, typeKey } from "../../ir/ir.js";
|
||||
import { isJsSourceFile, locOf } from "../program.js";
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import type { Lowerer } from "./lowerer.js";
|
||||
|
||||
function stripParens(node: ts.Expression): ts.Expression {
|
||||
while (ts.isParenthesizedExpression(node)) node = node.expression;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** `const { a, ...rest } = options ?? {}` (also `|| {}`) where the options
|
||||
* record has a member that cannot be absent. The pattern destructures
|
||||
* either the record or an empty object, which has no own keys, so the
|
||||
* source lowers to a union of the record and the empty record: the union
|
||||
* pattern lowering reads each key from the arm that holds it and answers
|
||||
* undefined from the empty arm, and a rest element packs the record's
|
||||
* remaining fields or nothing. The options operand is evaluated once.
|
||||
*
|
||||
* Null (the caller keeps its current lowering) for JavaScript files, other
|
||||
* initializer forms, an operand the checker does not type as one record
|
||||
* plus nullish arms, and a record whose every field may be absent: the
|
||||
* existing lowering already builds the empty object as that record with
|
||||
* every key absent. */
|
||||
export function nullishEmptyPatternSource(
|
||||
lowerer: Lowerer,
|
||||
pattern: ts.BindingName,
|
||||
initializer: ts.Expression,
|
||||
): IrExpr | null {
|
||||
if (!ts.isObjectBindingPattern(pattern)) return null;
|
||||
if (isJsSourceFile(ts.getSourceFileOfNode(initializer))) return null;
|
||||
const init = stripParens(initializer);
|
||||
if (
|
||||
!ts.isBinaryExpression(init) ||
|
||||
(init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken &&
|
||||
init.operatorToken.kind !== ts.SyntaxKind.BarBarToken)
|
||||
)
|
||||
return null;
|
||||
const right = stripParens(init.right);
|
||||
if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return null;
|
||||
const loc = locOf(init);
|
||||
// Decide from the checker's view of the operand before lowering it once.
|
||||
const declared = lowerer.mapTypeOf(lowerer.typeOf(init.left));
|
||||
if (declared?.kind !== "union") return null;
|
||||
const declaredArms = lowerer.unions.get(declared.unionId)?.arms ?? [];
|
||||
const declaredRecords = declaredArms.filter((arm) => arm.kind === "record");
|
||||
if (
|
||||
declaredRecords.length !== 1 ||
|
||||
!declaredArms.every((arm) => arm.kind === "record" || isUnitType(arm))
|
||||
)
|
||||
return null;
|
||||
const declaredShape = lowerer.shapes.get(
|
||||
(declaredRecords[0] as IrType & { kind: "record" }).shapeId,
|
||||
);
|
||||
if (
|
||||
!declaredShape ||
|
||||
declaredShape.tuple ||
|
||||
declaredShape.indexValue ||
|
||||
declaredShape.fields.length === 0 ||
|
||||
declaredShape.fields.every(
|
||||
(field) => lowerer.absentFieldValue(field.type, loc)?.kind === "fieldAbsent",
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const left = lowerer.lowerExpr(init.left);
|
||||
// The stored operand can differ from the checker's view. A stored record
|
||||
// is never nullish (the fallback is dead); any other stored form must be
|
||||
// the same one record plus nullish arms.
|
||||
if (left.type.kind === "record") return left;
|
||||
if (left.type.kind !== "union") {
|
||||
lowerer.unsupported(
|
||||
"SC1031",
|
||||
init,
|
||||
"destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)",
|
||||
);
|
||||
}
|
||||
const sourceId = left.type.unionId;
|
||||
const arms = lowerer.unions.get(sourceId)?.arms ?? [];
|
||||
const records = arms.filter((arm) => arm.kind === "record");
|
||||
if (records.length !== 1 || !arms.every((arm) => arm.kind === "record" || isUnitType(arm))) {
|
||||
lowerer.unsupported(
|
||||
"SC1031",
|
||||
init,
|
||||
"destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)",
|
||||
);
|
||||
}
|
||||
const record = records[0] as IrType & { kind: "record" };
|
||||
const empty: IrType = { kind: "record", shapeId: lowerer.shapes.intern([]) };
|
||||
const resultArms = [record, empty].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
|
||||
const resultType: IrType = { kind: "union", unionId: lowerer.unions.intern(resultArms) };
|
||||
const sourceTag = arms.indexOf(record);
|
||||
const tmp = lowerer.declareHiddenLocal("%nullishEmpty", left.type);
|
||||
const ref: IrExpr = { kind: "varRef", localId: tmp.id, type: left.type, loc };
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts: [{ kind: "varDecl", localId: tmp.id, init: left, loc }],
|
||||
result: {
|
||||
kind: "ternary",
|
||||
cond: {
|
||||
kind: "unionIsTag",
|
||||
unionId: sourceId,
|
||||
tag: sourceTag,
|
||||
negated: false,
|
||||
value: ref,
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
then: {
|
||||
kind: "unionWrap",
|
||||
unionId: resultType.unionId,
|
||||
tag: resultArms.indexOf(record),
|
||||
value: {
|
||||
kind: "unionNarrow",
|
||||
unionId: sourceId,
|
||||
tag: sourceTag,
|
||||
value: ref,
|
||||
type: record,
|
||||
loc,
|
||||
},
|
||||
type: resultType,
|
||||
loc,
|
||||
},
|
||||
else_: {
|
||||
kind: "unionWrap",
|
||||
unionId: resultType.unionId,
|
||||
tag: resultArms.indexOf(empty),
|
||||
value: { kind: "recordLit", fields: [], type: empty, loc },
|
||||
type: resultType,
|
||||
loc,
|
||||
},
|
||||
type: resultType,
|
||||
loc,
|
||||
},
|
||||
type: resultType,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
@@ -2183,3 +2183,54 @@ test("a caught-typed global records a module error only from a catch binding", (
|
||||
'in main: assign to catch binding "%error" (frontend must reject)',
|
||||
]);
|
||||
});
|
||||
|
||||
test("a record arm without the literal key answers undefined in unionKeyGet", () => {
|
||||
const uX: IrType = { kind: "union", unionId: "uX" };
|
||||
const uS: IrType = { kind: "union", unionId: "uS" };
|
||||
const rA: IrType = { kind: "record", shapeId: "rA" };
|
||||
const module = (answer: IrType): IrModule => ({
|
||||
irVersion: 17,
|
||||
sourceFile: loc.file,
|
||||
entry: "main",
|
||||
records: [
|
||||
{ id: "rA", fields: [{ name: "x", type: uX }] },
|
||||
{ id: "rE", fields: [] },
|
||||
],
|
||||
unions: [
|
||||
{ id: "uX", arms: [STRING, UNDEFINED_T] },
|
||||
{ id: "uS", arms: [rA, { kind: "record", shapeId: "rE" }] },
|
||||
],
|
||||
functions: [
|
||||
{
|
||||
name: "main",
|
||||
params: [{ localId: "p", name: "p", type: uS }],
|
||||
locals: [{ id: "p", name: "p", type: uS, mutable: false }],
|
||||
returnType: answer,
|
||||
body: [
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "unionKeyGet",
|
||||
unionId: "uS",
|
||||
key: { kind: "strLit", value: "x", type: STRING, loc },
|
||||
value: { kind: "varRef", localId: "p", type: uS, loc },
|
||||
type: answer,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
},
|
||||
],
|
||||
});
|
||||
// The empty arm lacks "x": it has no such own property, so it answers
|
||||
// the result's undefined arm.
|
||||
expect(validateModule(module(uX))).toEqual([]);
|
||||
// Without an undefined arm in the result there is no answer to give.
|
||||
expect(
|
||||
validateModule(module(STRING)).some((d) =>
|
||||
d.message.includes("unionKeyGet: arm 1 of uS has no answer for the key"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -3869,6 +3869,14 @@ function validateFunction(
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A declared-only arm that lacks a literal key has no such own
|
||||
// property: it answers undefined, like a unit arm.
|
||||
if (
|
||||
literal !== null &&
|
||||
!shape.indexValue &&
|
||||
resultUnion?.arms.some((a) => a.kind === "undefinedT")
|
||||
)
|
||||
return;
|
||||
if (!shape.indexValue && !(literal === null && shape.fields.length > 0)) {
|
||||
err(`unionKeyGet: arm ${i} of ${e.unionId} has no answer for the key`, e.loc);
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/record-nullish-empty-destructure.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// `const { a, ...rest } = options ?? {}` (and `|| {}`): the pattern
|
||||
// destructures either the options record or an empty object. Each key reads
|
||||
// undefined from the empty object, a default applies to it, and the rest
|
||||
// element packs the record's remaining own keys or nothing. The options
|
||||
// expression is evaluated once.
|
||||
|
||||
interface EditorOptions {
|
||||
description: string;
|
||||
paddingX: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function editor(options?: EditorOptions): string {
|
||||
const { description, ...editorOptions } = options ?? {};
|
||||
return `${description} ${JSON.stringify(editorOptions)} [${Object.keys(editorOptions).join(",")}]`;
|
||||
}
|
||||
console.log(editor());
|
||||
console.log(editor({ description: "d", paddingX: 2 }));
|
||||
console.log(editor({ description: "d", paddingX: 2, label: "l" }));
|
||||
|
||||
// Every member optional: the empty object is the same layout with every
|
||||
// key absent.
|
||||
interface StreamOptions {
|
||||
apiKey?: string;
|
||||
env?: string;
|
||||
signal?: string;
|
||||
transformHeaders?: (headers: string) => string;
|
||||
}
|
||||
|
||||
function stream(options?: StreamOptions): string {
|
||||
const { transformHeaders, apiKey, ...rest } = options ?? {};
|
||||
const headers = transformHeaders ? transformHeaders("h") : "none";
|
||||
return `${headers} ${apiKey} ${JSON.stringify(rest)} ${"signal" in rest}`;
|
||||
}
|
||||
console.log(stream());
|
||||
console.log(stream({ apiKey: "k", signal: "s" }));
|
||||
console.log(stream({ transformHeaders: (h) => h + "!", env: "e" }));
|
||||
|
||||
// A nested optional record, renamed keys and a default.
|
||||
interface Entry {
|
||||
options?: { deferred: boolean; signal?: string; step: number };
|
||||
}
|
||||
|
||||
function submit(entry: Entry): string {
|
||||
const { deferred: _deferred, signal: _signal, step = -1, ...submission } = entry.options ?? {};
|
||||
return `${step} ${JSON.stringify(submission)}`;
|
||||
}
|
||||
console.log(submit({}));
|
||||
console.log(submit({ options: { deferred: true, step: 4 } }));
|
||||
console.log(submit({ options: { deferred: false, signal: "x", step: 0 } }));
|
||||
|
||||
// `||` and a null arm.
|
||||
interface Retry {
|
||||
headers: string;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
function retry(value: Retry | null): string {
|
||||
const { headers, retries = 3 } = value || {};
|
||||
return `${headers} ${retries} ${typeof retries}`;
|
||||
}
|
||||
console.log(retry(null));
|
||||
console.log(retry({ headers: "h", retries: 2 }));
|
||||
|
||||
// A generic options parameter.
|
||||
function generic<TOptions extends { apiKey?: string; maxTokens: number }>(options?: TOptions): string {
|
||||
const { apiKey, ...providerOptions } = options ?? {};
|
||||
return `${apiKey} ${JSON.stringify(providerOptions)}`;
|
||||
}
|
||||
console.log(generic());
|
||||
console.log(generic({ maxTokens: 3, apiKey: "k" }));
|
||||
|
||||
// The options expression runs once.
|
||||
let calls = 0;
|
||||
function current(): EditorOptions | undefined {
|
||||
calls++;
|
||||
return calls === 1 ? undefined : { description: "second", paddingX: 1 };
|
||||
}
|
||||
const { description: first } = current() ?? {};
|
||||
const { description: second, paddingX } = current() ?? {};
|
||||
console.log(first, second, paddingX, calls);
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 6298, "san": 72106 }
|
||||
Reference in New Issue
Block a user