Choose comptime union arms by discriminant and refuse recursive records

A comptime record value whose union has a discriminant takes the arm its
discriminant field names, so a value that fits two arms with overlapping
shapes gets that arm's tag and layout instead of the first arm it fits.
comptimeBakeable reports a record type reachable from its own fields as
not bakeable instead of recursing without end, so such a comptime result
reports SC1090, and the message names unions and that limit.
This commit is contained in:
Chris Tate
2026-10-20 02:01:48 -05:00
parent fe322ae901
commit 45431aec32
4 changed files with 74 additions and 13 deletions
@@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr
"SC1090",
expr,
`baking comptime results of type '${lowerer.fmt(target)}' into the binary ` +
`(only number, string, boolean, arrays, and records lower to literals)`,
`(number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not)`,
);
}
// 5. The host executes the extracted callback under a finite budget.
@@ -112,25 +112,36 @@ function comptimeFailureDetail(error: unknown): string {
* expression: number/string/boolean, arrays and record shapes of those,
* and unions of those with null or undefined (the mixed rows of a JSON
* table: `(string | number)[][]`), nested. Closures, class instances,
* promises, dyn, and void cannot be baked. */
* promises, dyn, void, and recursive record types (a record reachable
* from its own fields) cannot be baked. */
export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean {
return bakeableWithin(lowerer, t, new Set());
}
/** comptimeBakeable below the records in `open`, whose fields are being
* checked: reaching one of them again means the type is recursive. */
function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set<string>): boolean {
switch (t.kind) {
case "f64":
case "string":
case "bool":
return true;
case "array":
return lowerer.comptimeBakeable(t.elem);
return bakeableWithin(lowerer, t.elem, open);
case "record": {
const shape = lowerer.shapes.get(t.shapeId);
return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type));
if (!shape || open.has(t.shapeId)) return false;
open.add(t.shapeId);
const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open));
open.delete(t.shapeId);
return bakeable;
}
case "union": {
const arms = lowerer.unions.get(t.unionId)?.arms;
return (
arms !== undefined &&
arms.every(
(arm) => isUnitType(arm) || (arm.kind !== "union" && comptimeBakeable(lowerer, arm)),
(arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)),
)
);
}
@@ -139,6 +150,30 @@ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean {
}
}
/** The tag of the arm of union `unionId` that `value` bakes into, or -1.
* A record value whose discriminant field holds one of the union's
* discriminant values takes that case's arm, since arms with overlapping
* shapes can both fit it. Any other value takes the first arm it fits, in
* the union's arm order: a JSON value fits exactly one scalar arm, and
* array or record arms are told apart by their contents. */
function comptimeUnionArm(lowerer: Lowerer, unionId: string, value: unknown): number {
const def = lowerer.unions.get(unionId);
if (!def) return -1;
const discriminant = def.discriminant;
if (
discriminant !== undefined &&
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
const key = (value as Record<string, unknown>)[discriminant.field];
const named = discriminant.cases.find((c) => c.values.some((v) => v === key));
if (named !== undefined)
return comptimeFits(lowerer, value, def.arms[named.tag]!) ? named.tag : -1;
}
return def.arms.findIndex((arm) => comptimeFits(lowerer, value, arm));
}
/** Whether `value` bakes into the bakeable type `t` without a mismatch:
* the arm choice of a union literal. Records are exact, as in
* comptimeValueToIr; an absent field reads as undefined. */
@@ -168,9 +203,7 @@ function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean {
);
}
case "union":
return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) =>
comptimeFits(lowerer, value, arm),
);
return comptimeUnionArm(lowerer, t.unionId, value) >= 0;
default:
return false;
}
@@ -313,11 +346,8 @@ export function comptimeValueToIr(
return { kind: "recordLit", fields, type: expected, loc };
}
case "union": {
// The first arm the value fits, in the union's arm order: a JSON
// value fits exactly one scalar arm, and array or record arms are
// told apart by their contents.
const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked
const tag = arms.findIndex((arm) => comptimeFits(lowerer, value, arm));
const tag = comptimeUnionArm(lowerer, expected.unionId, value);
if (tag < 0) fail(describeComptimeValue(value));
const arm = arms[tag]!;
const payload: IrExpr = isUnitType(arm)
+17
View File
@@ -43,3 +43,20 @@ const meta = comptime(() => {
return { kind: "meta", tags, count: tags.length };
});
console.log(meta.kind, meta.tags.join("|"), meta.count);
// A discriminated union whose arms both fit a "labeled" value without a
// label: the discriminant picks the arm.
type Item = { kind: "plain"; v: number } | { kind: "labeled"; v: number; label?: string };
const items: Item[] = comptime(
() =>
[
{ kind: "plain", v: 1 },
{ kind: "labeled", v: 2 },
{ kind: "labeled", v: 3, label: "x" },
] as Item[],
);
for (const item of items) {
if (item.kind === "labeled") console.log("labeled", item.v, item.label);
else console.log("plain", item.v);
}
console.log(JSON.stringify(items));
+7
View File
@@ -20,3 +20,10 @@ const nested = comptime(() => comptime(() => 1));
const eventually = comptime(async () => 1);
const lied = comptime(() => 1 as unknown as string);
// A record type reachable from its own fields has no literal form.
interface Chain {
value: number;
next: Chain | null;
}
const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain);
+8 -1
View File
@@ -45,4 +45,11 @@ comptime.ts:22:23 - error SC1110: comptime evaluation failed: expected 'string'
21 |
22 | const lied = comptime(() => 1 as unknown as string);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
23 |
23 |
comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not) is not supported yet
28 | }
29 | const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
30 |