pi wave 6: records and unions (#2597)

This commit is contained in:
Chris Tate
2026-10-29 22:55:28 -05:00
committed by GitHub
27 changed files with 817 additions and 30 deletions
@@ -193,6 +193,26 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
if (exact.length === 1) selected = exact;
else if (exact.length === 0 && lossless.length === 1) selected = lossless;
}
// An 'unknown' (dyn) source whose union destination has no dyn arm of
// its own (`details: any` into `Details | undefined`, a tool result's
// `structuredContent: unknown` into the JSON-value union): the checked
// extraction, the same dynCheck coerceToExpected performs for a dyn
// value in a union slot. A dyn source has no arm family, so every arm
// rule above has passed it by, and every plan that exists for a dyn
// source is the one above (its own arm) — this is exactly the case
// that returned null (SC2002) before, so no convertible value changes
// its lowering.
if (
selected.length !== 1 &&
src.kind === "dyn" &&
canDynCheckTo(
dst,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
)
) {
return { how: "dynOut" };
}
if (selected.length !== 1) return null;
return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm };
}
@@ -632,7 +632,14 @@ function arrayHofHelper(
* original concrete array arm — never a copied `(T | U)[]` — and an
* arm-specific function adapter wraps the element and receiver arguments
* into the union-wide callback ABI. That preserves sparse-array reads,
* callback mutation, and the callback's third-argument identity. */
* callback mutation, and the callback's third-argument identity.
*
* `filter` joins them: the kept elements are the checker's result element
* (`(b): b is TextContent` over `(TextContent | ImageContent)[] |
* (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm
* filters its own array and converts its survivors through the checked
* narrowing — a predicate that kept an element it does not describe throws
* the TypeError. An arm with no such conversion leaves the fence in place. */
export function lowerArrayUnionHofCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -640,7 +647,7 @@ export function lowerArrayUnionHofCall(
receiverT: IrType & { kind: "union" },
): IrExpr | null {
const method = access.name.text;
if (method !== "map" && method !== "forEach") return null;
if (method !== "map" && method !== "forEach" && method !== "filter") return null;
if (!lowerer.isStdlibMember(access)) return null;
const def = lowerer.unions.get(receiverT.unionId);
if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null;
@@ -682,8 +689,45 @@ export function lowerArrayUnionHofCall(
lowerer.badType(call, lowerer.typeOf(call));
}
if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet);
const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID;
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) {
if (fnRet.kind === "void") {
lowerer.unsupported(
"SC1090",
call,
"'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)",
);
}
lowerer.badType(call, lowerer.typeOf(call));
}
// `map` stores the callback's answer; `filter` keeps the elements its
// predicate named. The kept element is the CHECKER's result element, which
// for a written type predicate is narrower than every arm's own:
// `(b): b is TextContent` over `(TextContent | ImageContent)[] |
// (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm
// filters its own concrete array and converts its survivors to that
// element through the checked narrowing — a predicate that kept an element
// it does not describe throws instead of misreading it. No conversion the
// arms cannot make honestly means the fence stays.
let outElem: IrType = VOID;
const narrowing: (string | null)[] = [];
if (method === "map") {
outElem = callbackArrayElement(lowerer, call, fnRet);
} else if (method === "filter") {
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
if (!result || result.kind !== "array") return null;
outElem = result.elem;
const typeText = filterTypeText(lowerer, call);
for (const array of arrays) {
if (typeEquals(array.elem, outElem)) {
narrowing.push(null);
continue;
}
const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call));
if (helper === null) return null;
narrowing.push(helper);
}
}
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
for (const array of arrays) {
const armFnT = funcOf(
[arrayValueType(lowerer, array.elem), F64, array].slice(0, arity),
@@ -700,6 +744,7 @@ export function lowerArrayUnionHofCall(
arity,
fnRet,
outElem,
narrowing,
locOf(call),
);
return {
@@ -716,24 +761,31 @@ export function lowerArrayUnionHofCall(
function arrayUnionHofHelper(
lowerer: Lowerer,
method: "map" | "forEach",
method: "map" | "forEach" | "filter",
receiverT: IrType & { kind: "union" },
arrays: (IrType & { kind: "array" })[],
callbackT: IrType & { kind: "func" },
arity: number,
fnRet: IrType,
outElem: IrType,
narrowing: (string | null)[],
loc: SrcLoc,
): string {
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`;
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
// A filter's per-arm conversions are part of what the helper does, so two
// sites that share receiver, callback and result but convert differently
// keep their own helper. `map`/`forEach` never narrow, and leaving their
// key untouched keeps the helpers a program already emitted byte-identical.
const narrowingKey =
method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : "";
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.union", key);
lowerer.arrHofHelpers.set(key, name);
const receiver = varRef("a.0", receiverT, loc);
const callback = varRef("f.0", callbackT, loc);
const branch = (array: IrType & { kind: "array" }): IrStmt[] => {
const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => {
const tag = lowerer.armTag(receiverT.unionId, array);
const concrete: IrExpr = {
kind: "unionNarrow",
@@ -751,16 +803,38 @@ function arrayUnionHofHelper(
if (!typeEquals(adapted.type, armFnT)) {
throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting");
}
const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem);
const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc };
const callee = arrayHofHelper(
lowerer,
method,
array.elem,
fnRet,
arity,
loc,
method === "filter" ? array.elem : outElem,
);
const invoke: IrExpr = {
kind: "call",
callee,
args: [concrete, adapted],
type: method === "filter" ? arrayOf(array.elem) : resultT,
loc,
};
// Survivors of this arm's own array, converted to the checker's result
// element. The helper throws for a kept element the predicate does not
// describe, so a lying predicate never reinterprets a value.
const armNarrow = narrowing[index] ?? null;
const answer: IrExpr =
armNarrow === null
? invoke
: { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc };
return resultT.kind === "void"
? [
{ kind: "exprStmt", expr: invoke, loc },
{ kind: "return", value: null, loc },
]
: [{ kind: "return", value: invoke, loc }];
: [{ kind: "return", value: answer, loc }];
};
let body = branch(arrays[arrays.length - 1]!);
let body = branch(arrays[arrays.length - 1]!, arrays.length - 1);
for (let i = arrays.length - 2; i >= 0; i--) {
const array = arrays[i]!;
body = [
@@ -775,7 +849,7 @@ function arrayUnionHofHelper(
type: BOOL,
loc,
},
then: branch(array),
then: branch(array, i),
else_: body,
loc,
},
@@ -38,6 +38,28 @@ import { arrayValueStore, primitiveElement } from "../array-values.js";
import { classGeneratorIterable } from "../class-iterables.js";
import { iteratorCanStep } from "../iterator-consumption.js";
/** The arm an EMPTY `[]` literal builds as: `arms`' first array arm, or
* null when the literal is not empty (an element selects the arm) or no
* arm is an array.
*
* An empty literal carries no element of its own — tsc infers `never[]`,
* whose f64-element mapping is the uninhabited element's representation,
* not one of the destination's arms — and every empty array is the same
* value whichever array family stores it, so several array arms raise no
* ambiguity. This is the ONE statement of that rule: a union slot, a
* contextual union, and the `??` default all ask it. A lone array slot
* states the same thing without asking. */
export function emptyArrayLiteralArm(
node: ts.Expression,
arms: readonly IrType[],
): (IrType & { kind: "array" }) | null {
let value = node;
while (ts.isParenthesizedExpression(value)) value = value.expression;
if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return null;
const arm = arms.find((candidate) => candidate.kind === "array");
return (arm as (IrType & { kind: "array" }) | undefined) ?? null;
}
/** JavaScript: whether `expr` is or holds an existing checked-dynamic
* object — a binding stored as a dyn value whose checker type is an
* object, or an array or object literal with such an element. Building
@@ -206,16 +228,12 @@ export function lowerArrayLiteral(
mapped = arrayOf(unitOnlyUnion(lowerer.unions));
// An EMPTY literal in a union slot (`const t: string[] | undefined =
// []`, the `[]` default argument against `string[] | undefined`) has
// no useful own type — tsc infers `never[]`, whose f64-element
// mapping is a representation for the uninhabited, not the slot's
// arm. When the contextual union has exactly ONE array arm there is
// no ambiguity: build the literal as that arm and let the slot's
// coercion wrap it. Two array arms would need tsc's own best-fit
// choice — none arises in practice; the fence stands.
if (ctxUnion && expr.elements.nodes.length === 0) {
// no useful own type: it builds as the union's first array arm (the
// emptyArrayLiteralArm rule) and the slot's coercion wraps it.
if (ctxUnion) {
const def = lowerer.unions.get(ctxUnion.unionId);
const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? [];
if (arrayArms.length === 1) mapped = arrayArms[0]!;
const arm = emptyArrayLiteralArm(expr, def?.arms ?? []);
if (arm) mapped = arm;
}
// A NON-EMPTY literal whose own type has no static home (the JS dyn
// fallback — a null/dyn mapping, or an inference that degraded to a
@@ -73,6 +73,7 @@ import { nsWritableTarget } from "../lower-namespaces.js";
import { arrayValueStore, tryLowerBytesReadNumberEquality } from "../array-values.js";
import { cjsExportMemberWriteGlobalOf, cjsInFileExportGlobalOf } from "../cjs-exports-object.js";
import { lowerUnionFieldWrite } from "./union-fields.js";
import { emptyArrayLiteralArm } from "./array-literals.js";
import { lowerAbstractEquality } from "../abstract-equality.js";
import { lowerInExpression } from "./relational.js";
import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js";
@@ -431,6 +432,23 @@ export function lowerNullishPair(
const right = lowerer.lowerExprExpecting(expr.right, rest[0]!);
return { kind: "nullish", left, right, type: rest[0]!, loc };
}
// An EMPTY array-literal default (`''.match(re) ?? []`) has no element of
// its own, so it builds as the left's array arm (the emptyArrayLiteralArm
// rule the union slot and the contextual union share) and the whole
// expression answers the left's array type instead of the checker's
// `never[]`-flavored second arm. A JavaScript `[]` is `any[]` and keeps
// the dynamic path.
if (
rest.length === 1 &&
rest[0]!.kind === "array" &&
!isJsSourceFile(ts.getSourceFileOfNode(expr.right))
) {
const arm = emptyArrayLiteralArm(expr.right, rest);
if (arm) {
const right = lowerer.lowerExprExpecting(expr.right, arm);
if (typeEquals(right.type, arm)) return { kind: "nullish", left, right, type: arm, loc };
}
}
let type = lowerer.irTypeOf(expr);
// A runtime-optional read can reach the default even when declarations
// call the left side present. Preserve a differently typed primitive
@@ -1359,20 +1359,38 @@ export function lowerObjectLiteral(
) {
const own = lowerer.shapes.get(type.shapeId)!;
const target = lowerer.shapes.get(expected.shapeId)!;
if (
!own.tuple &&
// The field set to compare is the LITERAL's: its checker type. The
// contextual type a few lines up can be wider than what the literal
// writes (an unannotated method checked against a generic slot's
// signature: `{ content, details }` under a contextual
// `AgentToolResult<unknown>` whose optional members the literal never
// mentions), and the destination still names the same fields as the
// literal, so the build can construct them directly. Only when the
// contextual build has no conversion into the destination at all: a
// value that converts today keeps its exact construction.
const ownType = lowerer.mapTypeOf(lowerer.typeOf(expr));
const ownChecked =
ownType?.kind === "record" &&
ownType.shapeId !== type.shapeId &&
lowerer.widthLiftPlan(type, expected) === null
? lowerer.shapes.get(ownType.shapeId)
: undefined;
const fits = (from: IrRecordShape): boolean =>
!from.tuple &&
!target.tuple &&
!own.indexValue &&
!from.indexValue &&
!target.indexValue &&
!shapeHasAccessorSlots(own) &&
!shapeHasAccessorSlots(from) &&
!shapeHasAccessorSlots(target) &&
target.fields.every(
(field) =>
own.fields.some((candidate) => candidate.name === field.name) ||
from.fields.some((candidate) => candidate.name === field.name) ||
(field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0),
) &&
own.fields.every((field) => target.fields.some((candidate) => candidate.name === field.name))
) {
from.fields.every((field) =>
target.fields.some((candidate) => candidate.name === field.name),
);
if (fits(own) || (ownChecked !== undefined && fits(ownChecked))) {
type = { kind: "record", shapeId: expected.shapeId };
}
}
@@ -122,6 +122,19 @@ function coerceSlot(lowerer: Lowerer, arg: IrExpr, want: IrType, what: string):
);
}
if (arg.type.kind === "union" && want.kind !== "union" && !containsDynOrUnit(want)) {
// A stored union at a slot whose signature names one concrete type: the
// checker approved the call, so the SITE's type is that slot type and the
// stored union is the declaration's wider one — an absence arm the
// compiler itself carries (a checked-dynamic JSON stringify's
// `string | undefined`), a stale capture, a widened inferred return.
// That is coerceToExpected's checked ARM rule, not a refusal to lay the
// union out: the proven arm's payload comes out, every other arm throws
// the existing catchable TypeError (divergence 38 — sound narrowing
// never reaches them). A union with no such arm keeps the refusal.
if (lowerer.armTag(arg.type.unionId, want) >= 0) {
const narrowed = lowerer.coerceToExpected(arg, want);
if (typeEquals(narrowed.type, want)) return narrowed;
}
lowerer.pushDiag(unionMismatchDiag(lowerer.fmt(want), lowerer.fmt(arg.type), arg.loc));
throw new PoisonError();
}
@@ -782,6 +782,7 @@ import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js";
import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js";
import { isSafeToRepeat } from "./expressions/evaluation-safety.js";
import { partialAssertionFields } from "./partial-assertions.js";
import { emptyArrayLiteralArm } from "./expressions/array-literals.js";
import { lowerTernary } from "./lower-exprs.js";
import {
maybePromiseReturnStmt,
@@ -14069,6 +14070,10 @@ export class Lowerer {
);
}
}
// An EMPTY literal carries no element to select an arm by: it
// builds as the union's first array arm (the emptyArrayLiteralArm
// rule the contextual union and the `?? []` default share).
if (!arm) arm = emptyArrayLiteralArm(x, arms) ?? undefined;
if (arm) {
const built = this.lowerArrayLiteral(
x,
@@ -14229,9 +14234,49 @@ export class Lowerer {
if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected);
if (this.returnsFreshCollection(node) && expected.kind !== "void")
return this.lowerExprExpecting(node, expected);
// A fresh object literal into a record result: the same
// destination-directed construction. An unannotated method inside a
// contextually typed literal is checked against the CONTEXTUAL
// signature's result while its own return type stays the literal's
// inferred (narrower) record — `ToolDefinition<Schema, Details>`
// methods returning `{ content, details }`. Building at the
// contextual type would then coerce that wider record into the
// declared result and refuse; the result slot is the layout.
if (this.returnsFreshRecord(node, expected)) return this.lowerExprExpecting(node, expected);
return this.asyncReturnValueOf(node, this.lowerExpr(node), expected);
}
/** A fresh object literal returned into a RECORD result whose ordinary
* construction cannot reach that result: not a thenable, so it is built
* at the result type like a synchronous return. An unannotated method
* inside a contextually typed literal is checked against the CONTEXTUAL
* signature's result while its own return type stays the literal's
* inferred (narrower) record — `ToolDefinition<Schema, Details>` methods
* returning `{ content, details }`. Building at the contextual type
* would then coerce that wider record into the declared result and
* refuse; the result slot is the layout. A literal that declares `then`
* is awaited instead, and a literal whose contextual build does convert
* keeps the ordinary lowering, so no convertible value changes. */
private returnsFreshRecord(node: ts.Expression, expected: IrType): boolean {
if (expected.kind !== "record") return false;
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isObjectLiteralExpression(e)) return false;
if (this.checker.getPropertyOfType(this.typeOf(e), "then") !== undefined) return false;
// The record the ordinary path builds: the literal's context, with an
// async return's `T | PromiseLike<T>` stripped to the awaited record
// (object-literals.ts does the same before mapping).
let context = this.checker.getContextualType(e) ?? this.typeOf(e);
if (
context.isUnionType() &&
ts.constituentTypes(context).some((t) => ts.symbolNameOf(t.getSymbol()) === "PromiseLike")
)
context = this.checker.getAwaitedType(context) ?? context;
const built = this.mapTypeOf(context);
if (!built || built.kind !== "record") return false;
return this.widthLiftPlan(built, expected) === null;
}
/** An array literal or a `new Map(...)`, `new Set(...)` or `new
* Array(...)` of the builtins: a fresh collection, never a thenable, so
* an async return takes the declared result type as its construction
@@ -14406,7 +14451,8 @@ export class Lowerer {
if (
this.adoptsReturnedPromise() &&
!this.returnsLiteralIntoDyn(node, expected) &&
!this.returnsFreshCollection(node)
!this.returnsFreshCollection(node) &&
!this.returnsFreshRecord(node, expected)
) {
// The async return lowers its operand untyped first (a promise
// adopts before its payload converts); a maybe-promise union needs
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5798-record-field-dyn-into-union.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5799-async-return-record-literal-slot.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8530-union-array-filter.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/empty-array-union-slot.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/lib-slot-union-arm-extraction.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/nullish-empty-array-default.ts"
],
"diags": []
}
@@ -0,0 +1,90 @@
// `.filter` on a union of arrays (`A[] | B[]`): the predicate runs over
// `A | B`, and the checker's result element is the arm the predicate names.
// The runtime value is one concrete arm's array, so the lowering must
// dispatch on that arm (unionIsTag), filter the arm's own array, and convert
// the survivors to the named element through the checked narrowing — never
// hand a union receiver to a static array intrinsic.
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
const PRELUDE = [
"interface TextContent { type: 'text'; text: string }",
"interface ImageContent { type: 'image'; data: string }",
"interface ThinkingContent { type: 'thinking'; thinking: string }",
"type MessageContent =",
" | TextContent[]",
" | (ImageContent | TextContent)[]",
" | (ThinkingContent | TextContent)[];",
];
type IrModule = {
entry: string;
functions: { name: string; body: unknown }[];
};
async function compileToIr(
body: string[],
): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-union-array-filter-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, [...PRELUDE, ...body].join("\n"));
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
return {
ok: false,
module: null,
diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
};
const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule;
expect(validateModule(module as never)).toEqual([]);
return { ok: true, module, diagnostics: "" };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function named(module: IrModule, part: string): string {
return JSON.stringify(module.functions.filter((fn) => fn.name.includes(part)));
}
test("filter on a union of arrays dispatches on the arm", async () => {
const compiled = await compileToIr([
"function texts(content: MessageContent): string {",
" return content",
" .filter((block): block is TextContent => block.type === 'text')",
" .map((block) => block.text)",
" .join(' ');",
"}",
"const blocks: MessageContent = [{ type: 'text', text: 'a' }];",
"console.log(texts(blocks));",
]);
expect(compiled.diagnostics).toBe("");
expect(compiled.ok).toBe(true);
const module = compiled.module!;
// The tag dispatch that reads the runtime arm…
const union = named(module, "%arr.union");
expect(union).toContain("unionIsTag");
// …and the checked narrowing each arm's survivors pass through, so a
// predicate that kept an element it does not describe throws.
expect(named(module, "%arr.narrow")).toContain("%narrow.checked");
});
test("filter on a union of arrays keeps the fence without a narrowing", async () => {
// A predicate that names no narrower element answers the union-wide
// element; converting an arm's array to that element is a widening this
// lowering does not build, so the call stays a refusal instead of
// compiling to a different array layout.
const compiled = await compileToIr([
"function counts(content: MessageContent): number {",
" return content.filter((block): boolean => block.type !== 'image').length;",
"}",
"console.log(counts([]));",
]);
expect(compiled.ok).toBe(false);
expect(compiled.diagnostics).toContain("SC2020");
});
@@ -139,3 +139,25 @@ export {};
if (!result.ok) return;
expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0);
});
test("an empty array literal takes the union's first array arm instead of trapping", async () => {
const result = await lower(`
type TextContent = { type: "text"; text: string };
type ImageContent = { type: "image"; url: string };
interface Message {
content: TextContent[] | ImageContent[];
}
const message: Message = { content: [] };
function lists(): string[] | number[] {
return [];
}
console.log(JSON.stringify(message.content), JSON.stringify(lists()));
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
// An empty literal carries no element of its own, so it is built as the
// union's first array arm: no stranded-source trap and no element rebuild.
expect(helpers(result.ir, "%union.strand")).toEqual([]);
expect(helpers(result.ir, "%union.arr")).toEqual([]);
});
@@ -0,0 +1,85 @@
// A record field the checker types `any`/`unknown` flowing into a slot whose
// declared type is a UNION: the checked extraction (dynCheck) is the
// conversion, exactly as for a dyn value in a top-level union slot. Before
// this, such a field only lifted into a NON-union destination, so a tool
// result's `details: unknown` could not reach `Details | undefined`, a
// `structuredContent: unknown` could not reach a JSON-value union, and a
// spread's dynamic key could not reach an index-signature member.
interface Details {
limit: number;
flag?: boolean;
}
interface Wide {
label: string;
details: any;
}
interface Narrow {
label: string;
details: Details | undefined;
}
function narrow(w: Wide): Narrow {
return w;
}
type Json = string | number | boolean | null | { [key: string]: Json } | Json[];
interface Payload {
text: string;
structuredContent: any;
}
interface JsonPayload {
text: string;
structuredContent: Json | undefined;
}
function widen(p: Payload): JsonPayload {
return p;
}
// A spread whose source carries the dynamic field: the same field lift.
interface Built {
content: string;
sections: any;
}
interface Message {
role: string;
content: string;
sections: { [key: string]: string | null } | undefined;
timestamp: number;
}
function fromState(flag: boolean): Built {
return { content: flag ? "on" : "off", sections: { preamble: "p" } };
}
function message(flag: boolean): Message {
return { role: "system", ...fromState(flag), timestamp: 0 };
}
// A callback whose result parameter narrows the dynamic field back to a
// record type adapts into the slot's dynamic-details signature.
interface SlotResult {
content: { type: string; text: string }[];
details: any;
}
interface ToolResult {
content: { type: string; text: string }[];
details: { limit: number } | undefined;
}
type Renderer = (result: SlotResult, expanded: boolean) => string;
function format(result: ToolResult, expanded: boolean): string {
return `${result.content.length}:${result.details?.limit ?? "none"}:${expanded}`;
}
const render: Renderer = (result, expanded) => format(result, expanded);
const wide: Wide = { label: "a", details: { limit: 3 } };
const n = narrow(wide);
console.log(n.label, n.details === undefined ? "none" : n.details.limit);
const p = widen({ text: "t", structuredContent: { nested: [1, true, null] } });
console.log(p.text, JSON.stringify(p.structuredContent ?? null));
const m = message(true);
console.log(m.role, m.content, m.timestamp, m.sections?.["preamble"] ?? "-");
console.log(render({ content: [{ type: "text", text: "abc" }], details: { limit: 7 } }, true));
console.log(render({ content: [], details: undefined }, false));
@@ -0,0 +1,76 @@
// A tool definition whose `execute` is an unannotated async method inside a
// contextually typed object literal. tsc checks the method against the
// contextual signature's result (`AgentToolResult<Loaded>`) but INFERS its
// own return type from the returned literal (`{ content: { type, text }[];
// details: { loaded } }`). The fresh literal must construct at the declared
// result slot, not at the contextual type it was checked against: the
// contextual type's `content` is the wider content-part union array, which
// no copy narrows back into the inferred element record.
interface TextContent {
type: string;
text: string;
textSignature?: string;
}
interface ImageContent {
type: string;
data: string;
mimeType: string;
}
interface Candidate {
name: string;
score: number;
}
interface Definition<D> {
name: string;
search(query: string): Promise<D>;
execute(id: string, query: string): Promise<Result<D>>;
}
interface Result<D> {
content: (TextContent | ImageContent)[];
details: D;
structuredContent?: unknown;
isError?: boolean;
}
interface Loaded {
loaded: string[];
}
function rank(query: string, names: string[]): Candidate[] {
return names.filter((name) => name.includes(query)).map((name) => ({ name, score: name.length }));
}
function makeDefinition(names: string[]): Definition<Loaded> {
return {
name: "search",
async search(query: string) {
return { loaded: rank(query, names).map((candidate) => candidate.name) };
},
async execute(_id: string, query: string) {
return {
content: [{ type: "text", text: rank(query, names).map((c) => c.name).join(",") }],
details: { loaded: rank(query, names).map((c) => c.name) },
};
},
};
}
// The same shape through a record of definitions, each with its own details.
type AnyDefinition = Definition<any>;
function makeNamed(names: string[]): Record<"search", AnyDefinition> {
return { search: makeDefinition(names) };
}
const definition = makeDefinition(["find-file", "grep-tree", "read-file"]);
definition
.search("file")
.then((loaded) => {
console.log("search:", loaded.loaded.join(","));
return definition.execute("call-1", "file");
})
.then((result) => {
console.log("execute:", result.content.length, result.content[0]!.type, result.details.loaded.join(","));
return makeNamed(["read-file"]).search.execute("call-2", "file");
})
.then((second) => {
console.log("second:", second.content.length, second.details.loaded.join(","));
});
+86
View File
@@ -0,0 +1,86 @@
// `.filter` on a UNION OF ARRAYS (`A[] | B[]`, TS 5.2's callable-union
// rule): the predicate runs over `A | B`, and the checker's result element
// is the one the predicate names — not any arm's own element. The runtime
// value is one concrete arm's array, so the filter dispatches on that arm,
// keeps the arm's survivors in order, and converts each survivor to the
// named element.
interface TextContent {
type: "text";
text: string;
}
interface ImageContent {
type: "image";
data: string;
}
interface ThinkingContent {
type: "thinking";
thinking: string;
}
interface ToolCall {
type: "tool";
name: string;
}
type MessageContent =
| TextContent[]
| (ImageContent | TextContent)[]
| (ThinkingContent | ToolCall | TextContent)[];
function textOf(content: MessageContent): string {
return content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join(" ");
}
const contents: MessageContent[] = [
[{ type: "text", text: "a" }, { type: "text", text: "b" }],
[
{ type: "image", data: "AA" },
{ type: "text", text: "c" },
],
[
{ type: "thinking", thinking: "t" },
{ type: "tool", name: "n" },
{ type: "text", text: "d" },
],
];
for (const content of contents) console.log("text", textOf(content));
// A predicate written as a function EXPRESSION rather than an arrow: the
// same receiver, the same per-arm dispatch.
function expressions(content: MessageContent): string {
return content
.filter(function (block): block is TextContent {
return block.type === "text";
})
.map((block) => block.text)
.join(" ");
}
console.log("expr", expressions(contents[1]!), expressions(contents[2]!));
// The predicate sees the union-wide element, so an arm-specific test is
// legal; the survivors still convert per arm.
function longTexts(content: MessageContent): string[] {
return content
.filter((block): block is TextContent => block.type === "text" && block.text !== "c")
.map((block) => block.text);
}
console.log("long", longTexts(contents[1]!).join("+"), longTexts(contents[2]!).join("+"));
// Node's `filter` reads the length once and visits elements in order; the
// arm dispatch must not change either, and the result is a fresh array.
let seen = 0;
function counted(content: MessageContent): number {
const kept = content.filter((block): block is TextContent => {
seen++;
return block.type === "text";
});
return kept.length;
}
console.log("counted", counted(contents[2]!), "seen", seen);
// The empty array is spelled with its arm's type: a bare `[]` literal
// assigned to this three-arm array union is a separate, unrelated gap (the
// literal's `number[]` element has no arm conversion), so it would obscure
// what this program pins.
const empty: MessageContent = [] as TextContent[];
console.log("empty", textOf(empty), counted(empty), "seen", seen);
+106
View File
@@ -0,0 +1,106 @@
// An EMPTY array literal in a union of array slots (`content: TextContent[] |
// ImageContent[]`, `string[] | number[]`). tsc types the literal `never[]`, an
// element with no representation of its own: the literal used to lower as
// never's f64-element array (`number[]`) and the slot's retag threw
// "a 'number[]' value is not representable in the target union". An empty
// array is the same value whichever array family stores it, so the literal is
// built as the union's first array arm — the rule a lone array slot states —
// and every shape below reaches Node's answer.
type TextContent = { type: "text"; text: string };
type ImageContent = { type: "image"; url: string };
interface Message {
role: string;
content: TextContent[] | ImageContent[];
}
// A nested record field: the reported shape.
const empty: Message = { role: "user", content: [] };
console.log(JSON.stringify(empty));
// A declaration, its initialization and a later reassignment.
let wide: string[] | number[] = [];
console.log(JSON.stringify(wide));
wide = [1, 2];
console.log(JSON.stringify(wide));
wide = [];
console.log(JSON.stringify(wide));
// A declared return, a ternary arm, and an async promise's payload.
function none(): string[] | number[] {
return [];
}
console.log(JSON.stringify(none()), none() !== none());
function either(take: boolean): string[] | number[] {
return take ? [] : [1, 2];
}
console.log(JSON.stringify(either(true)), JSON.stringify(either(false)));
function bothWays(): string[] | number[] {
return false ? [] : [];
}
console.log(JSON.stringify(bothWays()));
async function settled(): Promise<string[] | number[]> {
return [];
}
void settled().then((value) => console.log("settled", JSON.stringify(value)));
// An argument slot, and a default parameter that is itself the slot.
function report(label: string, value: string[] | number[]): void {
console.log(label, JSON.stringify(value));
}
report("argument", []);
report("argument-filled", ["x"]);
function withDefault(value: string[] | number[] = []): void {
console.log("default", JSON.stringify(value));
}
withDefault();
withDefault([3]);
// Deeper nesting, a class field, and a discriminated record's field.
interface Nested {
inner: { list: readonly string[] | readonly number[]; tag: string };
}
const nested: Nested = { inner: { list: [], tag: "t" } };
console.log(JSON.stringify(nested));
class Store {
lists: string[] | number[] = [];
}
const store = new Store();
console.log("class", JSON.stringify(store.lists), store.lists !== undefined);
// An optional arm (`| undefined`) keeps the empty literal on its array arm.
const maybe: string[] | number[] | undefined = [];
console.log("maybe", maybe !== undefined, JSON.stringify(maybe));
// The empty literal as an element of an array, and through collections whose
// element slot is the union.
const rows: (string[] | number[])[] = [[], [1, 2], []];
console.log("rows", JSON.stringify(rows));
const grouped = new Set<string[] | number[]>();
grouped.add([]);
grouped.add(["s"]);
console.log("set", JSON.stringify([...grouped]));
const byKey = new Map<string, string[] | number[]>();
byKey.set("a", []);
byKey.set("b", [7]);
console.log("map", JSON.stringify([...byKey.entries()]));
// A callback argument, and the assertion form of the same value.
function apply(fn: (value: string[] | number[]) => string): string {
return fn([]);
}
console.log("callback", apply((value) => JSON.stringify(value)));
const asserted = { role: "tool", content: [] } as Message;
console.log("asserted", JSON.stringify(asserted));
// Every evaluation builds a fresh empty array, as Node does.
function fresh(): string[] | number[] {
return [];
}
const first = fresh();
const second = fresh();
console.log("fresh", first !== second, JSON.stringify(first), JSON.stringify(second));
@@ -0,0 +1,33 @@
// A checked-dynamic JSON stringify — a record or array carrying an `unknown`
// field — answers `string | undefined` in the IR (the runtime walker can
// return undefined for a dyn root) while the checker types the call `string`.
// Reading a string member of that result is the checked ARM extraction at the
// stdlib slot: the proven arm's payload comes out, and the absent arm would
// throw the catchable TypeError instead of the call being refused.
interface Tool {
name: string;
parameters: unknown;
}
function sizes(tools: Tool[], extra: { note: string; value: unknown }): string {
const text = JSON.stringify(tools);
let chars = 0;
chars += text.length;
if (tools.length > 0) chars += JSON.stringify(tools[0]).length;
chars += JSON.stringify(extra).slice(0, 24).length;
chars += JSON.stringify({ tools }).toUpperCase().slice(0, 12).length;
chars += JSON.stringify(tools).indexOf('"name"');
chars += `raw:${JSON.stringify(extra)}`.length > 0 ? 1 : 0;
return `${chars} ${text.length} ${JSON.stringify({ tools }).slice(0, 12)}`;
}
console.log(sizes([{ name: "alpha", parameters: { a: 1 } }], { note: "n", value: null }));
console.log(sizes([{ name: "beta", parameters: undefined }], { note: "empty", value: [1, 2] }));
console.log(sizes([], { note: "none", value: { nested: { deep: true } } }));
console.log(sizes([{ name: "gamma", parameters: "s" }], { note: "deep", value: { a: [1, { b: 2 }] } }));
// The same string result feeding a comparison and a concatenation.
const one = JSON.stringify({ name: "x", parameters: 1 });
const two = JSON.stringify({ parameters: 1, name: "x" });
console.log(one === two, one < two, ("p" + one).length);
@@ -0,0 +1,40 @@
// `''.match(re) ?? []`: the empty array-literal default of a `??` adopts the
// left's present array arm. tsc gives the literal the uninhabited `never[]`
// element, which the compiler maps through never's f64 value representation
// (`number[]`), so the whole expression used to be `RegExpMatchArray |
// number[]` — a result no single array slot can receive.
// A union return (`"all" | readonly string[]`) whose array arm is the
// `?? []` result.
function reach(code: unknown): "all" | readonly string[] {
if (typeof code !== "string") return [];
return /\bALL\b/.test(code) ? "all" : (code.match(/\w+/g) ?? []);
}
console.log(JSON.stringify(reach(7)));
console.log(JSON.stringify(reach("ALL")));
console.log(JSON.stringify(reach("a.b c")));
console.log(JSON.stringify(reach("")));
// The default's element type is the left's: string element reads and a
// map callback over the value compile as strings, and an absent match
// answers the empty array Node does.
const names = "a-b c".match(/[a-z]+/g) ?? [];
console.log(names.map((name) => name.toUpperCase()).join("+"));
const none = "123".match(/[a-z]+/g) ?? [];
console.log(none.length, JSON.stringify(none), names.length);
// A declared optional array keeps its own type through the same default,
// and the fresh empty array is a value of its own (mutation, identity).
function fill(source: number[] | undefined, with_: number): number[] {
const list = source ?? [];
list.push(with_);
return list;
}
console.log(JSON.stringify(fill(undefined, 1)), JSON.stringify(fill([7, 8], 9)));
const first = fill(undefined, 2);
const second = fill(undefined, 3);
console.log(first !== second, JSON.stringify(first), JSON.stringify(second));
// A parenthesized default and one inside a longer expression.
const longest = ("abbbc".match(/b+/g) ?? []).length;
console.log(longest, `${JSON.stringify("xyz".match(/q/g) ?? [])}!`);
@@ -0,0 +1 @@
{ "plain": 1800, "san": 1900 }
@@ -0,0 +1 @@
{ "plain": 1800, "san": 1900 }
@@ -0,0 +1 @@
{ "plain": 1900, "san": 2200 }
@@ -0,0 +1 @@
{ "plain": 2700, "san": 3400 }
@@ -0,0 +1 @@
{ "plain": 1517, "san": 2343 }
@@ -0,0 +1 @@
{ "plain": 922, "san": 1502 }