pi wave 5: union layouts and reads, provider and tool records (#2547)

This commit is contained in:
Chris Tate
2026-10-29 15:25:45 -05:00
committed by GitHub
64 changed files with 1843 additions and 56 deletions
@@ -630,6 +630,41 @@ export function checkDefaultParamBodyType(
}
}
/** Whether a lambda's OWN signature maps to its completed ABI position for
* position — the contract its reads carry (mapType spells `x?: T` as the
* `T | undefined` slot). Two positions can escape the mapped-TYPE
* comparison in requireExactArityValue: a parameter the checker spells
* `any` holds the checked-dynamic value that mapType leaves unmapped when
* its spelling is context-free, and a *contextually* typed lambda's own
* signature need not be the slot's (a comparator with a surplus optional
* parameter under a two-parameter slot). */
function lambdaSpellsCompletedAbi(
lowerer: Lowerer,
lambda: ts.ArrowFunction | ts.FunctionExpression,
funcType: IrType,
): boolean {
if (funcType.kind !== "func" || funcType.rest === true) return false;
const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda));
if (sigs.length !== 1) return false;
const sig = sigs[0]!;
const params = sig.getParameters();
if (params.length !== funcType.params.length) return false;
for (let i = 0; i < params.length; i++) {
const pTs = lowerer.checker.getTypeOfSymbol(params[i]!);
const slot = funcType.params[i]!;
if ((pTs.flags & ts.TypeFlags.Any) !== 0) {
if (slot.kind !== "dyn") return false;
continue;
}
const mappedParam = lowerer.mapTypeOf(pTs);
if (!mappedParam || !typeEquals(mappedParam, slot)) return false;
}
const retTs = lowerer.checker.getReturnTypeOfSignature(sig);
if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn";
const mappedRet = lowerer.mapTypeOf(retTs);
return mappedRet !== null && typeEquals(mappedRet, funcType.ret);
}
/** DECISION (docs/ir.md): function VALUES carry one completed native ABI.
* Optional/default parameters appear as literal `T | undefined` slots;
* typed rest parameters appear as one trailing typed-array slot plus the
@@ -675,6 +710,20 @@ export function requireExactArityValue(
? lowerer.mapTypeOf(lowerer.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// A lambda whose OWN signature already spells the completed ABI is a
// value: every read of it types it that way, and its slot converts it
// (the surplus-parameter and undefined-arm adapters). The mapped-type
// comparison above misses two such lambdas — one whose `any` parameter
// mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number`
// beside a two-parameter slot), and one whose signature is wider than
// the contextual slot it is written under.
if (
contextual !== null &&
(ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) &&
lambdaSpellsCompletedAbi(lowerer, contextual, funcType)
) {
return;
}
// An 'any' slot (`C.prototype.m = function (a, b) {…}` through an
// untyped receiver), or a slot whose type maps to a checked-dynamic
// value (a member of a literal lowered with a generic function's type
@@ -114,10 +114,28 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool
return false;
}
/** Whether an omitted parameter position completes with the native
* undefined value: a checked-dynamic slot takes undefined as-is, and an
* undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI
* spelling. */
export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true;
return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0;
}
/** Whether all parameters and the result adapt without a trap-only path.
* Rest signatures are excluded, except a typed-rest source in a slot with
* fixed parameters (the rest packing adapter); extra destination
* parameters are ignored. */
* parameters are ignored. A source with MORE parameters than the slot is
* clean when every extra trailing one is omittable — the adapter passes
* that absent value explicitly (funcCoerceAdapter's omittedParams), the
* same way a call through the slot would. A parameter the slot coercion
* converts through a STRUCTURAL width counts (cleanParamPair), which
* recurses through a nested function type: a callback parameter of the
* value taking a narrower record where the slot's takes its `unknown` view
* converts the argument the same way a top-level call does. The result side
* likewise accepts what funcCoerceAdapter converts (cleanResult), including
* a promise whose payload widens. */
export function cleanFuncAdaptable(
lowerer: Lowerer,
src: IrType & { kind: "func" },
@@ -125,13 +143,35 @@ export function cleanFuncAdaptable(
): boolean {
if (restPackingAdapterFits(lowerer, src, dst)) return true;
if (src.rest === true || dst.rest === true) return false;
if (src.params.length > dst.params.length) return false;
for (let i = 0; i < src.params.length; i++) {
if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false;
// Recursive function types (`type Handler = (h: Handler) => void`) reach
// the same pair again one parameter down: assume it converts, the same
// coinductive answer recordWidthPlan gives its own back-edges.
const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`;
if (lowerer.coercions.planning.has(key)) return true;
lowerer.coercions.planning.add(key);
try {
for (let i = dst.params.length; i < src.params.length; i++) {
if (!omittableTrailingParam(lowerer, src.params[i]!)) return false;
}
for (let i = 0; i < src.params.length && i < dst.params.length; i++) {
if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false;
}
return cleanResult(lowerer, src.ret, dst.ret);
} finally {
lowerer.coercions.planning.delete(key);
}
if (dst.ret.kind === "void") return true;
if (lowerer.coercibleValue(src.ret, dst.ret)) return true;
return src.ret.kind === "void" && dst.ret.kind === "dyn";
}
/** Whether the slot's coercion converts the value's RESULT without a
* trap-only path: the dispositions funcCoerceAdapter's result side accepts
* (a void slot drops the value; coercibleValue; a structural width, which
* includes the promise payload widening and a nested function adaptation;
* the void-into-unknown drop). */
function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean {
if (expected.kind === "void") return true;
if (lowerer.coercibleValue(actual, expected)) return true;
if (actual.kind === "void" && expected.kind === "dyn") return true;
return lowerer.widthLiftPlan(actual, expected) !== null;
}
/** Whether the slot's argument type `actual` converts into the function's
@@ -56,6 +56,7 @@ import { typeKey } from "../../type-mapper.js";
import {
lowerArrayCallback,
requireProducedArrayElement,
unitOnlyArrayElement,
callbackArrayElement,
truthyFilterCallback,
} from "./callback-arguments.js";
@@ -69,6 +70,7 @@ import {
import { dynamicReceiverThrows } from "./dynamic-receivers.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { checkedFilterNarrowing } from "./filter-predicates.js";
import { omittableTrailingParam } from "../coercions/function-adapters.js";
/** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct
* call of a synthetic module function — one per method + element/result
@@ -108,7 +110,14 @@ export function lowerArrayHofCall(
}
const callback = booleanFilter
? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false }
: lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem));
: lowerArrayCallback(
lowerer,
argNode,
[valueT],
arrayOf(elem),
false,
method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined,
);
const { fnArg, arity } = callback;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
@@ -659,7 +668,14 @@ export function lowerArrayUnionHofCall(
? valueArms[0]!
: { kind: "union", unionId: lowerer.unions.intern(valueArms) };
const argNode = call.arguments.nodes[0]!;
const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT);
const { fnArg, arity } = lowerArrayCallback(
lowerer,
argNode,
[valueT],
receiverT,
false,
method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined,
);
const fnRet = fnArg.type.ret;
if (method === "map" && fnRet.kind === "void") {
// `void[]` has no element (see the plain-receiver map path).
@@ -1806,10 +1822,10 @@ export function lowerArrayFlatMapCall(
/** How each arm of a flatMap callback's union result contributes to the
* output: an array arm appends its elements, an array arm the checker
* types `never[]` (an empty literal such as `s ? s : []`) appends nothing,
* and any other arm appends itself. */
* a tuple arm appends its fields, and any other arm appends itself. */
interface FlatMapUnionPlan {
outElem: IrType;
arms: ("spread" | "empty" | "value")[];
arms: ("spread" | "empty" | "tuple" | "value")[];
}
/** The plan for a callback returning `T | U[]`: every non-array arm must
@@ -1827,15 +1843,18 @@ function flatMapUnionPlan(
const outElem = out.elem;
const arms = lowerer.unions.get(fnRet.unionId)?.arms;
if (!arms) return null;
// The `never[]` constituents of the callback's checked result.
// The constituents of the callback's checked result that can hold no
// element: a `never[]` arm, and the empty tuple a bare `[]` arm infers
// when the enclosing call has a contextual array type (`return x ? [a]
// : []` in a `f(): T[]`).
const emptyArms: IrType[] = [];
const signature = lowerer.checker.getCallSignatures(lowerer.typeOf(argNode))[0];
if (signature) {
const ret = lowerer.checker.getReturnTypeOfSignature(signature);
for (const part of ret.isUnionType() ? ts.constituentTypes(ret) : [ret]) {
if (!lowerer.checker.isArrayType(part)) continue;
const [element] = lowerer.checker.getTypeArguments(part as ts.TypeReference);
if (element === undefined || (element.flags & ts.TypeFlags.Never) === 0) continue;
if (!lowerer.checker.isArrayType(part) && !lowerer.checker.isTupleType(part)) continue;
const element = lowerer.checker.getTypeArguments(part as ts.TypeReference)[0];
if (element !== undefined && (element.flags & ts.TypeFlags.Never) === 0) continue;
const mapped = lowerer.mapTypeOf(part);
if (mapped) emptyArms.push(mapped);
}
@@ -1847,12 +1866,19 @@ function flatMapUnionPlan(
if (outElem.kind !== "union") return false;
return lowerer.armTag(outElem.unionId, t) >= 0;
};
const tupleConverts = (arm: IrType & { kind: "record" }): boolean =>
(lowerer.shapes.get(arm.shapeId)?.fields ?? []).every((f) => converts(f.type));
const plan: FlatMapUnionPlan["arms"] = [];
for (const arm of arms) {
if (arm.kind === "array" && emptyArms.some((e) => typeEquals(e, arm))) plan.push("empty");
// An array arm copies its elements as they are stored, a present
// undefined included, so its element type is the result's.
else if (arm.kind === "array" && typeEquals(arm.elem, outElem)) plan.push("spread");
// A contextual array type makes a multi-statement body's `[pair]`
// infer as a one-element TUPLE (`return x ? [pair] : []`), whose
// fields contribute in order.
else if (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple && tupleConverts(arm))
plan.push("tuple");
else if (arm.kind !== "array" && converts(arm)) plan.push("value");
else return null;
}
@@ -1908,6 +1934,32 @@ function flatMapUnionHelper(
loc,
};
if (plan.arms[tag] === "empty") return [];
if (plan.arms[tag] === "tuple") {
const tuple = arm as IrType & { kind: "record" };
// The fields are stored by name ("0", "1", "10", "2", …): the
// elements append in index order.
const elements = [...(lowerer.shapes.get(tuple.shapeId)?.fields ?? [])].sort(
(a, b) => Number(a.name) - Number(b.name),
);
return elements.map((field) => {
const read: IrExpr = {
kind: "recordGet",
obj: narrowed,
shapeId: tuple.shapeId,
field: field.name,
type: field.type,
loc,
};
return arrayValueStore(
lowerer,
outRef(),
outLength(),
lowerer.coerceToExpected(read, plan.outElem),
plan.outElem,
loc,
);
});
}
if (plan.arms[tag] === "value") {
// A unit arm's value is the unit itself, which has no narrowed
// representation.
@@ -2642,11 +2694,15 @@ export function lowerArraySortCall(
let fnArg =
undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT);
// Reusable JS comparators often accept checked values. Adapt the array's
// element ABI just as other array callbacks do before sorting.
// element ABI just as other array callbacks do before sorting. A
// comparator declaring MORE parameters than sort passes (`(a, b, loose?)`,
// the imported semver shape) receives the surplus ones as undefined — the
// completion an ordinary two-argument call performs — so it adapts to the
// (a, b) slot like any other wider signature.
if (
fnArg.type.kind === "func" &&
fnArg.type.params.length <= 2 &&
fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param))
fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) &&
fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param))
) {
// Arithmetic on inferred JS values retains a checked result because
// it can produce BigInt. The sort callback still requires a number.
@@ -2655,7 +2711,7 @@ export function lowerArraySortCall(
? F64
: fnArg.type.ret;
const expected = funcOf(
fnArg.type.params.map(() => elem),
fnArg.type.params.slice(0, 2).map(() => elem),
result,
);
if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected);
@@ -56,6 +56,8 @@ import {
import {
requireProducedArrayElement,
lowerArrayCallback,
lowerArrayCallbackAnswering,
unitOnlyArrayElement,
callbackArrayElement,
} from "./callback-arguments.js";
import { classGeneratorIterable } from "../class-iterables.js";
@@ -1002,6 +1004,8 @@ export function lowerArrayFromCall(
args[1]!,
[input.element],
arrayOf(input.element),
false,
unitOnlyArrayElement(lowerer, call) ?? undefined,
);
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
@@ -1123,7 +1127,14 @@ export function lowerArrayFromCall(
if (paramSymbol) lowerer.runtimeOptionalBindingTypes.set(paramSymbol, valueT);
let callback: ReturnType<typeof lowerArrayCallback>;
try {
callback = lowerArrayCallback(lowerer, mapper, [valueT], arrT);
callback = lowerArrayCallback(
lowerer,
mapper,
[valueT],
arrT,
false,
unitOnlyArrayElement(lowerer, call) ?? undefined,
);
} finally {
if (paramSymbol) {
if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(paramSymbol);
@@ -1162,7 +1173,14 @@ export function lowerArrayFromCall(
// mapper that writes the source changes later reads, as in Node),
// so this is a loop over the typed array, not a toArray snapshot.
if (src.type.kind === "bytes" && args.length === 2) {
const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64));
const callback = lowerArrayCallback(
lowerer,
args[1]!,
[F64],
arrayOf(F64),
false,
unitOnlyArrayElement(lowerer, call) ?? undefined,
);
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
const fnRet = callback.fnArg.type.ret;
@@ -1245,7 +1263,7 @@ export function lowerArrayFromCall(
);
}
if (n.type.kind !== "f64") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!));
const fnArg = lowerer.lowerExpr(args[1]!);
const fnArg = lowerArrayCallbackAnswering(lowerer, args[1]!, unitOnlyArrayElement(lowerer, call));
// The mapper may declare any prefix of (v, i): v is the checker's own
// `unknown` (Node passes undefined there — the dyn undefined singleton
// here), i the index. The result type must be a legal array element.
@@ -306,6 +306,42 @@ function readsParameter(argNode: ts.Expression, index: number): boolean {
);
}
/** The result array's element when it is the unit-only union (`undefined[]`,
* the sparse-literal/`Array.from(..., () => undefined)` shape): a callback
* that answers `undefined` stores a PRESENT unit element, so the producer
* gives the callback that union as its contextual return. The standalone
* `undefined` in return position otherwise maps to the void convention,
* whose answer is erased before it could be stored. Null for other elements. */
export function unitOnlyArrayElement(lowerer: Lowerer, call: ts.CallExpression): IrType | null {
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
if (result?.kind !== "array") return null;
const elem = result.elem;
if (isUnitType(elem)) return elem;
return elem.kind === "union" &&
lowerer.unions.get(elem.unionId)?.arms.every((arm) => isUnitType(arm)) === true
? elem
: null;
}
/** Lower a callback whose answer becomes an array element, with the result
* element as its contextual return when that element is the unit-only union
* (see unitOnlyArrayElement). Other producers lower the callback directly. */
export function lowerArrayCallbackAnswering(
lowerer: Lowerer,
fnNode: ts.Expression,
expected: IrType | null,
): IrExpr {
if (expected === null) return lowerer.lowerExpr(fnNode);
const previous = lowerer.contextualFunctionReturns.get(fnNode);
lowerer.contextualFunctionReturns.set(fnNode, expected);
try {
return lowerer.lowerExpr(fnNode);
} finally {
if (previous === undefined) lowerer.contextualFunctionReturns.delete(fnNode);
else lowerer.contextualFunctionReturns.set(fnNode, previous);
}
}
/** Preserve the checker-selected payload ABI when a callback's runtime
* return gained only undefined. The array state byte carries that value. */
export function callbackArrayElement(
@@ -163,7 +163,13 @@ function conversionBody(
target.indexValue
)
return null;
if (source.fields.length !== target.fields.length) return null;
// A written predicate usually names FEWER members than the receiver's
// element has (`(c): c is { text: string } => ...` over a block with a
// `textSignature` and a `type`): the target's members are a subset of
// the source's, each read and converted, and the copy keeps the
// source's keys in the source's order. A member the source lacks has
// no slot to read, so it stays refused.
if (target.fields.length > source.fields.length) return null;
const fields: { name: string; value: IrExpr }[] = [];
for (const field of target.fields) {
const own = source.fields.find((f) => f.name === field.name);
@@ -216,7 +222,13 @@ function keepAbsence(
}
/** A union value to one of its arms, or to a union of some of its arms:
* each arm the target holds returns, any other arm throws. */
* each arm the target holds returns, any other arm throws.
*
* A written predicate may name a structural type that is no arm at all
* (`(c): c is { text: string; type: string } => ...` over the block union
* above, whose text arm also carries `textSignature`): when no arm matches
* exactly, each arm the target can be read from converts by its own shape,
* tag by tag, and an arm with no such read throws. */
function unionConversion(
lowerer: Lowerer,
input: IrExpr,
@@ -279,7 +291,10 @@ function unionConversion(
// Every arm the target names must be one of the union's own arms, or a
// record target converts from at least one arm. A record target that is
// itself an arm keeps that arm's elements as they are, and the arms with
// its fields convert.
// its fields convert. Any other structural target that is no arm converts
// from each arm it can be read from.
if (kept === 0 && recordArmConversions === 0 && to.kind !== "union")
return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc);
if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null;
body.push(
typeError(
@@ -289,3 +304,42 @@ function unionConversion(
);
return body;
}
/** A union value to a structural type that is no arm: each arm the target
* can be read from returns its converted value, any other arm throws. */
function structuralUnionConversion(
lowerer: Lowerer,
input: IrExpr,
unionId: string,
to: IrType,
typeText: string,
loc: SrcLoc,
): IrStmt[] | null {
const source = lowerer.unions.get(unionId);
if (!source) return null;
const body: IrStmt[] = [];
let kept = 0;
for (let tag = 0; tag < source.arms.length; tag++) {
const arm = source.arms[tag]!;
if (isUnitType(arm)) continue;
const payload: IrExpr = { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc };
const value = converted(lowerer, payload, arm, to, loc);
if (value === null) continue;
kept++;
body.push({
kind: "if",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc },
then: [{ kind: "return", value, loc }],
else_: null,
loc,
});
}
if (kept === 0) return null;
body.push(
typeError(
`a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`,
loc,
),
);
return body;
}
@@ -3014,6 +3014,44 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE
loc,
};
}
// `u || d` whose checker result is one of u's own arms while u keeps
// arms flow narrowing ruled out (`typeof o === "string" ? ... : o ||
// {}` leaves the string arm in the stored union). Test u in its own
// union and extract the result arm through the checked narrowing: a
// value of a ruled-out arm traps rather than masquerading as the
// result type.
if (op === ts.SyntaxKind.BarBarToken && left.type.kind === "union") {
const def = lowerer.unions.get(left.type.unionId);
if (
target !== null &&
def !== undefined &&
def.arms.some(isUnitType) &&
lowerer.armTag(left.type.unionId, target) >= 0
) {
const helper = lowerer.narrowedArmHelper(left.type.unionId, target, loc);
if (helper) {
lowerer.requireTruthyUnion(left.type.unionId, expr);
const stmts: IrStmt[] = [];
let stable: IrExpr = left;
if (!isSafeToRepeat(left)) {
const local = lowerer.declareHiddenLocal("%or", left.type);
stmts.push({ kind: "varDecl", localId: local.id, init: left, loc });
stable = varRef(local.id, left.type, loc);
}
const result: IrExpr = {
kind: "ternary",
cond: lowerer.ensureBool(stable, expr.left),
then: { kind: "call", callee: helper, args: [stable], type: target, loc },
else_: lowerer.lowerExprExpecting(expr.right, target),
type: target,
loc,
};
return stmts.length === 0
? result
: { kind: "seqExpr", stmts, result, type: target, loc };
}
}
}
lowerer.unsupported(
"SC1090",
expr,
@@ -1534,6 +1534,20 @@ export function lowerRecordKeyRead(
recordKeyResultOk(lowerer, shape, DYN)
)
declared = DYN;
// A `keyof` read over a record that holds an 'unknown' member: the
// checker's answer collapses to 'unknown' (the union of every field
// type absorbs into it), so there is no typed join to dispatch into.
// Each declared field still reads through its checked-dynamic slot —
// the very value the checker's 'unknown' can hold — so dispatch per
// declared key and answer a checked-dynamic read. The key type is the
// record's own `keyof`, so the answer is present whenever the read is.
if (
!declared &&
(lowerer.typeOf(expr).flags & ts.TypeFlags.Unknown) !== 0 &&
!shape.indexValue &&
recordKeyResultOk(lowerer, shape, DYN)
)
declared = DYN;
if (!declared) {
lowerer.unsupported(
"SC1090",
@@ -1617,6 +1617,19 @@ export function lowerPropertyAccessExpr(
}
const asserted = assertedMemberRead(lowerer, expr.expression, expr.name.text, recvLowered, loc);
if (asserted) return asserted;
// Lowering erases flow narrowing, so a checker-NARROWED receiver keeps
// the wider union it stores (`stored?.type` after the earlier arm
// returned, `(entry as { role: string }).role`): the checker's type
// names one record while the value still carries its siblings. Read the
// member off whichever arm the value holds — an arm that does not
// declare it answers undefined, exactly Node's get — and narrow the
// answer to the checker's type, which traps on an arm the checker's
// narrowing ruled out (types do not lie). Reaching this point means no
// lowering above claimed the read, so only refusals gain a lowering.
if (recvLowered.type.kind === "union") {
const read = lowerUnionValueRead(lowerer, expr, recvLowered, true);
if (read) return lowerer.maybeNarrow(read, expr);
}
// A checker-`any` receiver whose value is a FRESH typed result of an
// earlier step in the chain (`out.split(" ").length`, where split
// extracted the string and answered a native string[]): the member read
@@ -3532,9 +3545,21 @@ export function lowerUnionProperty(
expr: ts.PropertyAccessExpression,
): IrExpr | null {
if (lowerer.chainBlocked(expr)) return null;
const receiverIr =
const spelled =
structuralReadType(lowerer, expr.expression) ??
lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
// Inside an optional-chain body the guarded receiver is checker-typed by
// its non-nullish narrowing — often one record of a discriminated union
// — while the bound chain value still carries the whole present union
// (`stored?.type` after an earlier `stored?.type === "oauth"` return
// narrowed `stored` to `ApiKeyCredential`, though the runtime-optional
// storage still holds `ApiKeyCredential | OAuthCredential`). The union
// reader dispatches on the runtime tag, so take the union the chain
// binding hands to `lowerExpr` below instead of the spelled narrowing.
const chainRecv = lowerer.chainRecvByNode.get(expr.expression);
const chainUnion =
spelled?.kind !== "union" && chainRecv?.type.kind === "union" ? chainRecv.type : null;
const receiverIr = chainUnion ?? spelled;
// The checker can rule the receiver UNREACHABLE and the assertion that
// escapes it (`const x: never = event; (event as any).type` — the
// `default:` branch of a switch that exhausts every arm) erases to
@@ -3568,6 +3593,16 @@ export function lowerUnionProperty(
// extract the instance and test its class, as typed slots do.
else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value;
}
// The spelled type is a narrowing, so every path below (dyn, record,
// object, array) was unreachable for this read before it took the stored
// union: answer it only when the union reader can, and otherwise hand the
// read back to the lowering the spelled type used to reach. A stored arm
// the checker ruled out that has no such member (`m1?.name` where the
// stored union still carries a `string` arm) must not turn an accepted
// program into a refusal.
if (chainUnion !== null) {
return value.type.kind === "union" ? lowerUnionValueRead(lowerer, expr, value) : null;
}
// A checker-union receiver whose VALUE lowered to a plain RECORD (the
// merged-signature fiction — `runner(cmd, args)` where runner joined
// a structural runner type with spawnSync's, and the local adopted
@@ -3642,8 +3677,33 @@ export function lowerUnionProperty(
"shared property access without a represented union receiver",
);
}
const def = lowerer.unions.get(value.type.unionId);
if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`);
const read = lowerUnionValueRead(lowerer, expr, value);
if (read) return read;
lowerer.unsupported(
"SC1090",
expr,
`reading '${expr.name.text}' on a union-typed value (every arm must be an object/record ` +
`with a same-typed field '${expr.name.text}'; ` +
`${NARROW_FIRST})`,
);
}
/** The member read itself on a lowered UNION value: `lowerUnionProperty`
* after it has produced the value (retagged to the checker's union when
* the checker removed arms) and the dispatcher's narrowed-receiver
* fallback, which reaches the same read with the value a checker-narrowed
* receiver still stores. Returns null when no arm can answer — the caller
* owns the diagnostic. */
export function lowerUnionValueRead(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
value: IrExpr,
missingFieldAsUndefined = false,
): IrExpr | null {
if (value.type.kind !== "union") return null;
const unionId = value.type.unionId;
const def = lowerer.unions.get(unionId);
if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`);
const field = expr.name.text;
if (field === "length") {
const presentArms = def.arms.filter((arm) => !isUnitType(arm));
@@ -3675,6 +3735,42 @@ export function lowerUnionProperty(
};
}
}
// A union of arrays and strings (`Model[] | ModelRef[]`,
// `string[] | Buffer[]`): every arm answers its own length, so the
// read dispatches on the tag. Node's `.length` is a number for both,
// and no arm can be absent here (a unit arm would have to throw on
// this read, which the checker's narrowing already excludes).
if (
def.arms.length > 1 &&
def.arms.every((arm) => arm.kind === "array" || arm.kind === "string")
) {
return perArmUnionRead(
lowerer,
expr,
unionId,
value,
F64,
"%unionLength",
(arm, _tag, receiver) =>
arm.kind === "string"
? {
kind: "strIntrinsic",
method: "length",
receiver,
args: [],
type: F64,
loc: locOf(expr),
}
: {
kind: "arrIntrinsic",
method: "length",
receiver,
args: [],
type: F64,
loc: locOf(expr),
},
);
}
}
const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm));
if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") {
@@ -3688,11 +3784,7 @@ export function lowerUnionProperty(
field,
);
if (receiver === null) {
const helper = lowerer.narrowedArmHelper(
value.type.unionId,
objectType,
locOf(expr.expression),
);
const helper = lowerer.narrowedArmHelper(unionId, objectType, locOf(expr.expression));
if (helper !== null)
receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression));
}
@@ -3708,6 +3800,14 @@ export function lowerUnionProperty(
}
}
}
// A member the arms answer through an ACCESSOR (a getter inherited
// from a shared base class — `renderer.hasOverlayEntries` on
// `FullscreenRenderer | RegularRenderer`): there is no field slot
// `unionDisc` could read, so dispatch on the tag and call each arm's
// own getter. Fires only when some arm's answer is an accessor, so
// data-field unions keep their `unionDisc` lowering.
const accessorRead = lowerUnionAccessorRead(lowerer, expr, unionId, value);
if (accessorRead) return accessorRead;
let common: IrType | null = null;
for (const arm of def.arms) {
let ft: IrType | undefined;
@@ -3736,7 +3836,15 @@ export function lowerUnionProperty(
// arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv |
// Record<string, string>`, the tail read of `loaded?.config.script`.
const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) };
const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field);
const keyed = lowerUnionKeyedRead(
lowerer,
expr,
unionId,
value,
key,
field,
missingFieldAsUndefined,
);
if (keyed) return keyed;
// Class arms declaring the field with compatible but different types
// (`T | undefined` beside `T`): per-arm slot reads widened to one type.
@@ -3763,12 +3871,178 @@ export function lowerUnionProperty(
const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr));
if (member) return member;
}
lowerer.unsupported(
"SC1090",
return null;
}
/** Evaluate `value` once into a hidden local and answer the read by
* dispatching on the union's tag: arm `tag` (narrowed from the local)
* answers `answer(arm, tag, narrowed)`. The first non-unit arm's answer
* is the fallback branch, which a representable union never reaches. */
function perArmUnionRead(
lowerer: Lowerer,
expr: ts.Node,
unionId: string,
value: IrExpr,
type: IrType,
name: string,
answer: (arm: IrType, tag: number, receiver: IrExpr) => IrExpr,
): IrExpr {
const loc = locOf(expr);
const def = lowerer.unions.get(unionId);
if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`);
const stable = lowerer.declareHiddenLocal(name, value.type);
const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: value.type, loc });
let result: IrExpr | null = null;
def.arms.forEach((arm, tag) => {
if (isUnitType(arm)) return;
const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value: ref(), type: arm, loc };
const read = answer(arm, tag, receiver);
result =
result === null
? read
: {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId,
tag,
value: ref(),
negated: false,
type: BOOL,
loc,
},
then: read,
else_: result,
type,
loc,
};
});
if (result === null) throw new InternalCompilerError(`lowerer bug: no arm answers ${name}`);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: stable.id, init: value, loc }],
result,
type,
loc,
};
}
/** A member read the arms answer through an ACCESSOR — an inherited
* getter (`this.renderer.hasOverlayEntries`). `unionDisc` reads record
* and class FIELD slots, so a getter arm needs the tag dispatch: each
* arm calls its own getter (once per read, as Node's read does). Data
* arms in the same union read their slot. Every arm must answer one
* type; a unit arm or an arm without the member declines (the caller's
* keyed read and fences own those). Null when no arm answers through an
* accessor — plain field unions keep `unionDisc`. */
function lowerUnionAccessorRead(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
unionId: string,
value: IrExpr,
): IrExpr | null {
const def = lowerer.unions.get(unionId);
if (!def) return null;
const field = expr.name.text;
const getterName = `get:${field}`;
let type: IrType | null = null;
let anyAccessor = false;
const arms: { tag: number; read: (receiver: IrExpr) => IrExpr }[] = [];
for (let tag = 0; tag < def.arms.length; tag++) {
const arm = def.arms[tag]!;
if (isUnitType(arm)) return null;
let answer: IrType;
let read: (receiver: IrExpr) => IrExpr;
if (arm.kind === "record") {
const shape = lowerer.shapes.get(arm.shapeId);
if (!shape || shape.tuple) return null;
const data = shape.fields.find((f) => f.name === field);
if (data) {
answer = data.type;
read = (receiver) => ({
kind: "recordGet",
obj: receiver,
shapeId: arm.shapeId,
field,
type: data.type,
loc: locOf(expr),
});
} else {
const getter = shape.fields.find((f) => f.name === getterName);
if (!getter || getter.type.kind !== "func") return null;
anyAccessor = true;
answer = getter.type.ret;
read = (receiver) =>
lowerer.coerceToExpected(
{
kind: "callValue",
callee: {
kind: "recordGet",
obj: receiver,
shapeId: arm.shapeId,
field: getterName,
type: getter.type,
loc: locOf(expr),
},
args: [],
type: getter.type.kind === "func" ? getter.type.ret : DYN,
loc: locOf(expr),
},
answer,
);
}
} else if (arm.kind === "object") {
const info = lowerer.classes.get(arm.className) ?? null;
const slot = info?.fields.get(field);
if (slot !== undefined) {
answer = slot;
read = (receiver) => ({
kind: "fieldGet",
obj: receiver,
className: arm.className,
field,
type: slot,
loc: locOf(expr),
});
} else {
const getter = lowerer.findMethodOn(info, getterName);
if (!getter || getter.sig.ret.kind === "void") return null;
anyAccessor = true;
answer = getter.sig.ret;
read = (receiver) =>
lowerer.fieldGetExpr(
{
container: "accessor",
obj: receiver,
className: arm.className,
field,
fieldType: getter.sig.ret,
},
locOf(expr),
expr,
);
}
} else {
return null;
}
if (answer.kind === "void" || isUnitType(answer)) return null;
if (type !== null && !typeEquals(type, answer)) return null;
type = answer;
arms.push({ tag, read });
}
if (!anyAccessor || type === null || arms.length < 2) return null;
return perArmUnionRead(
lowerer,
expr,
`reading '${field}' on a union-typed value (every arm must be an object/record ` +
`with a same-typed field '${field}'; ` +
`${NARROW_FIRST})`,
unionId,
value,
type,
"%unionMember",
(_arm, tag, receiver) => {
const entry = arms.find((a) => a.tag === tag);
if (!entry) throw new InternalCompilerError("lowerer bug: accessor arm missing");
return entry.read(receiver);
},
);
}
@@ -4041,6 +4315,46 @@ function checkedDynamicValue(expr: IrExpr): IrExpr | null {
return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null;
}
/** A GENERIC receiver type: a type parameter, or a narrowing of one that
* intersects it (`"k" in options` refines a type variable to
* `T & Record<"k", unknown>`). Its stored layout is whatever the call site
* instantiated it with, and the CONSTRAINT's members are not necessarily
* stored there. */
function typeParameterReceiver(t: ts.Type): boolean {
try {
if ((t.flags & ts.TypeFlags.TypeParameter) !== 0) return true;
return (
(t.flags & ts.TypeFlags.Intersection) !== 0 &&
ts.constituentTypes(t).some((part) => (part.flags & ts.TypeFlags.TypeParameter) !== 0)
);
} catch (e) {
if (ts.isUnreadableConstituentsPanic(e)) return false;
throw e;
}
}
/** Whether `access` is only READ (not the target of an assignment, a
* delete, or an increment). The left operand of `??`, `===` or `&&`, and
* the operand of `!`, `-` or `typeof`, are reads. */
function readOnlyAccess(access: ts.PropertyAccessExpression): boolean {
const parent = access.parent;
if (parent === undefined) return true;
if (
ts.isBinaryExpression(parent) &&
parent.left === access &&
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
)
return false;
if (ts.isDeleteExpression(parent)) return false;
if (ts.isPostfixUnaryExpression(parent)) return false;
return (
!ts.isPrefixUnaryExpression(parent) ||
(parent.operator !== ts.SyntaxKind.PlusPlusToken &&
parent.operator !== ts.SyntaxKind.MinusMinusToken)
);
}
export function fieldTarget(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
@@ -4167,6 +4481,55 @@ export function fieldTarget(
lowerer.lowerExpr(access.expression),
);
}
// A generic receiver's member the INSTANTIATION cannot store: the type
// parameter's constraint declares the member (so tsc admits the read),
// while the record the parameter was instantiated with has no such slot —
// a generic options bag read through its constraint (`options.sessionId`
// under an `"sessionId" in options` guard) at a call site whose options
// literal omits the optional member. Node reads the property off the
// object it actually has: undefined when the caller omitted it, the extra
// property when a JavaScript caller supplied one. So the read is the
// checked-dynamic keyed read of the stored record, entering the static
// code at the type the checker gave the read.
if (
!access.questionDotToken &&
readOnlyAccess(access) &&
typeParameterReceiver(lowerer.typeOf(access.expression))
) {
const storedIr = stored ?? narrowed;
const arm = storedIr?.kind === "union" ? lowerer.stripUndefinedArm(storedIr) : storedIr;
const shape = arm?.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined;
const admitted = lowerer.mapTypeOf(lowerer.typeOf(access));
if (
shape &&
!shape.tuple &&
!shape.indexValue &&
!shape.fields.some((f) => f.name === access.name.text) &&
admitted &&
admitted.kind !== "void"
) {
let obj = lowerer.lowerExpr(access.expression);
if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) {
const present = lowerer.stripUndefinedArm(obj.type);
obj =
present.kind === "record"
? (lowerer.runtimeOptionalPropertyReceiver(
access.expression,
obj,
present,
access.name.text,
) ?? obj)
: lowerer.maybeNarrow(obj, access.expression);
}
if (obj.type.kind !== "record") return null;
return {
container: "dynamic",
obj: lowerer.coerceToExpected(obj, DYN),
field: access.name.text,
fieldType: admitted,
};
}
}
if (receiverIr?.kind === "record") {
const shape = lowerer.shapes.get(receiverIr.shapeId);
const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type;
@@ -10076,6 +10076,21 @@ function lowerProgramClassNew(
});
}
/** The supported map layout a contextual type pins for `new Map()`: the type
* itself, or the single map arm of a union — a nullable annotation such as
* `Map<K, V> | undefined`, or `ReadonlyMap<K, V> | undefined`, where a Map
* value can only inhabit the map arm and the no-argument constructor gives
* the checker nothing to infer the type arguments from. */
function contextualMapLayout(lowerer: Lowerer, ctx: ts.Type): IrType | null {
const mapped = lowerer.mapTypeOf(ctx);
if (mapped?.kind === "map") return mapped;
if (mapped?.kind !== "union") return null;
const arms = (lowerer.unions.get(mapped.unionId)?.arms ?? []).filter(
(arm): arm is IrType & { kind: "map" } => arm.kind === "map",
);
return arms.length === 1 ? arms[0]! : null;
}
function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null {
const parent = expr.parent;
if (
@@ -11071,10 +11086,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
mapped = lowerer.mapTypeOf(inferred);
} else if (mapped?.kind !== "map" || contextualSeed) {
const ctx = lowerer.checker.getContextualType(expr);
const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null;
if (ctx && ctxMapped?.kind === "map") {
const ctxMap = ctx ? contextualMapLayout(lowerer, ctx) : null;
if (ctx && ctxMap !== null) {
tsType = ctx;
mapped = ctxMapped;
mapped = ctxMap;
}
}
// `new Map(source)` the checker types `Map<any, any>` because the
@@ -14153,6 +14153,19 @@ export class Lowerer {
);
}
/** Whether `node`'s TS type is a `Promise<never>` (the throw-only async
* function's declared result): the promise can never settle, so the value
* it would resolve to is unreachable. The IR keeps `Promise<never>` as a
* promise with a void payload, exactly like `Promise<void>`, so the TS
* payload type is what separates the two. */
private promisePayloadIsNever(node: ts.Expression): boolean {
const t = this.typeOf(node);
if ((t.flags & ts.TypeFlags.Object) === 0) return false;
if (((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return false;
const args = this.checker.getTypeArguments(t as ts.TypeReference);
return args.length === 1 && (args[0]!.flags & ts.TypeFlags.Never) !== 0;
}
/** The value of `return <e>` (already lowered untyped) in an async or
* void context: a promise flattens through the returned-promise await,
* everything else converts into the declared result. */
@@ -14160,6 +14173,15 @@ export class Lowerer {
if (expected.kind === "void" && e.kind === "unitLit") return null;
if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") {
const awaited = this.returnedPromiseAwait(e);
// A `Promise<never>` never settles, so the awaited value is
// unreachable: it fills any result shape instead of failing the
// void-to-record conversion.
if (
expected.kind !== "void" &&
e.type.inner.kind === "void" &&
this.promisePayloadIsNever(node)
)
return this.divergentValue(awaited, expected);
return this.coerceInto(node, awaited, expected);
}
return this.coerceInto(node, e, expected);
@@ -0,0 +1,87 @@
// A member the checker admits only through a type parameter's constraint
// while the instantiation's record has no slot for it (`options.sessionId`
// under an `"sessionId" in options` guard, at a call site whose options
// literal omits the optional member): the read must lower to the
// checked-dynamic keyed read of the stored record — the object's own
// property, undefined when absent — instead of fencing.
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 RequestOptions<TModel> { model?: TModel; apiKey?: string; sessionId?: string }",
"interface Transforms {",
" transformHeaders?: (headers: Record<string, string | null>) => Record<string, string | null>;",
"}",
"function sessionOf<TModel, TOptions extends RequestOptions<TModel> & Transforms>(",
" options: TOptions | undefined,",
"): string | undefined {",
" return options && 'sessionId' in options && typeof options.sessionId === 'string'",
" ? options.sessionId",
" : undefined;",
"}",
];
type IrModule = {
entry: string;
functions: { name: string; body: unknown }[];
};
async function compileToIr(
lines: string[],
): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-generic-constraint-read-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, [...PRELUDE, ...lines].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 });
}
}
/** The IR bodies of the functions whose name starts with `root`, as text
* (a specialized function keeps its source name as a prefix). */
function bodies(module: IrModule, root: string): string {
return JSON.stringify(
module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body),
);
}
test("an omitted constraint member reads dynamically off the stored record", async () => {
const compiled = await compileToIr([
"const withoutKey = { apiKey: 'k' };",
"console.log(String(sessionOf<{ id: string }, typeof withoutKey>(withoutKey)));",
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
const body = bodies(compiled.module!, "sessionOf");
// The stored record has no `sessionId` slot, so the read is the
// checked-dynamic keyed read of the value (dynFrom → key read → check).
expect(body).toContain('"dynFrom"');
expect(body).toContain('"dynCheck"');
expect(body).not.toContain('"unsupported"');
});
test("a constraint member the instantiation stores still reads its slot", async () => {
const compiled = await compileToIr([
"const withKey = { apiKey: 'k', sessionId: 's-1' };",
"console.log(String(sessionOf<{ id: string }, typeof withKey>(withKey)));",
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
const body = bodies(compiled.module!, "sessionOf");
expect(body).toContain('"recordGet"');
expect(body).toContain('"sessionId"');
expect(body).not.toContain('"dynFrom"');
});
@@ -58,7 +58,7 @@ export {};
expect(validateModule(result.module)).toEqual([]);
});
test("a void-returning map callback still has no element to store", async () => {
test("a void-returning map callback stores undefined elements", async () => {
const result = await lower(`
const items = [1, 2];
const out = items.map((n) => {
@@ -67,8 +67,8 @@ const out = items.map((n) => {
console.log(out.length);
export {};
`);
// `void[]` stays unrepresentable: a void value is erased before it could
// be written. Only the CLOSURE element became an ordinary element.
expect(result.ok).toBe(false);
if (!result.ok) expect(result.codes).toEqual(["SC2009"]);
// A discarding mapper's answer is undefined, which the result array
// stores as its element (as Node's map does).
if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`);
expect(validateModule(result.module)).toEqual([]);
});
@@ -70,3 +70,29 @@ for (const v of inputs) console.log(width(v));
expect(result.stderr.toString()).toBe(UNION_TRAP);
},
);
test.skipIf(process.platform === "win32")(
"a lying filter predicate throws the checked-extraction TypeError",
async () => {
const result = await run(`
interface Cat {
kind: "cat";
meows: number;
}
interface Dog {
kind: "dog";
}
type Pet = Cat | Dog;
const pets: Pet[] = [{ kind: "cat", meows: 2 }, { kind: "dog" }];
const alwaysCat = (p: Pet): p is Cat => p !== undefined;
try {
console.log("kept", pets.filter(alwaysCat).length);
} catch (e) {
console.log("caught", (e as Error).name);
}
`);
expect(result.error).toBeUndefined();
// Node keeps the Dog and reports 2; the checked projection throws on it.
expect(result.stdout.toString()).toBe("caught TypeError\n");
},
);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8520-union-narrowed-receiver-reads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8521-union-accessor-member-read.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8522-union-array-length.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8523-settings-keyof-dynamic-read.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8524-filter-predicate-subset-record.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8525-flatmap-tuple-arms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8526-union-logical-default.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8527-array-callbacks-undefined-elements.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8528-new-collection-contextual-union.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8529-sort-comparator-surplus-params.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/flatmap-long-tuple-arms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-constraint-member-read.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-omitted-member-reads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/promise-never-return.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-func-field-width.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-read-after-chain-guard.ts"
],
"diags": []
}
@@ -0,0 +1,89 @@
// A member read on a union receiver inside an optional-chain body: the
// checker narrows the guarded receiver to ONE union arm (a discriminant
// test through `?.`), while the bound chain value still carries the whole
// present union — the runtime-optional local a prior `if (s?.kind === ...)
// return` narrowed after an `await`. The read must dispatch on the value's
// runtime tag (unionDisc), not fall through to the unsupported-property
// fence the spelled single-record type would suggest.
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 ApiKeyCredential { type: 'api_key'; key?: string }",
"interface OAuthCredential { type: 'oauth'; refresh: string; expires: number }",
"type Credential = ApiKeyCredential | OAuthCredential;",
];
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-chain-read-"));
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 });
}
}
/** The IR bodies of the functions whose name starts with `root`, as text
* (a specialized function keeps its source name as a prefix). */
function bodies(module: IrModule, root: string): string {
return JSON.stringify(
module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body),
);
}
test("a chain-guarded union read dispatches on the stored union", async () => {
const compiled = await compileToIr([
"function load(): Promise<Credential | undefined> {",
" return Promise.resolve({ type: 'api_key', key: 'k' } as Credential);",
"}",
"async function pick(): Promise<string> {",
" const stored = await load();",
" if (stored?.type === 'oauth') return stored.expires > 0 ? 'oauth' : 'expired';",
" const credential = stored?.type === 'api_key' ? stored : undefined;",
" return credential?.type === 'api_key' ? (credential.key ?? 'none') : 'missing';",
"}",
"pick().then((answer) => console.log(answer));",
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
// The guarded read still sees both stored arms; the tag switch is the
// shape that composes with the discriminant comparison.
const pick = bodies(compiled.module!, "pick");
expect(pick).toContain('"unionDisc"');
// The runtime-optional local crosses the await, so the presence test is
// the chain's own guard, not a checked extraction trap on every read.
expect(pick).not.toContain('"unsupported"');
});
test("a chain-guarded read of a non-discriminant field reads the survivor", async () => {
const compiled = await compileToIr([
"function only(c: Credential | undefined): string {",
" return c?.type === 'oauth' ? c.refresh : 'api';",
"}",
"console.log(only({ type: 'oauth', refresh: 'r', expires: 1 }));",
"console.log(only(undefined));",
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
expect(bodies(compiled.module!, "only")).toContain('"refresh"');
});
@@ -0,0 +1,84 @@
// Lowering erases flow narrowing, so a checker-NARROWED receiver still
// stores its wider union. A member read dispatches on the arm the value
// actually holds: an arm that declares the member answers its slot, an arm
// that does not answers undefined (Node's get), and an arm the narrowing
// ruled out traps in the checked conversion.
interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: Record<string, string>;
}
interface OAuthCredential {
type: "oauth";
access: string;
refresh: string;
expires: number;
}
type Credential = ApiKeyCredential | OAuthCredential;
function pick(stored: Credential | undefined): string | undefined {
if (stored?.type === "oauth") return stored.access;
const credential = stored?.type === "api_key" ? stored : undefined;
return credential?.key;
}
console.log("key", pick({ type: "api_key", key: "k1" }));
console.log("key absent", pick({ type: "api_key" }));
console.log("oauth", pick({ type: "oauth", access: "a1", refresh: "r1", expires: 1 }));
console.log("none", pick(undefined));
// An assertion narrows the checker's view of arms that carry different
// payloads; every arm declares the asserted member here.
interface Message {
role: string;
content: string;
}
interface ToolResult {
role: string;
output: string[];
}
type AgentMessage = Message | ToolResult;
function roleOf(message: AgentMessage): string {
return (message as { role: string }).role;
}
console.log("role", roleOf({ role: "user", content: "hi" }));
console.log("role", roleOf({ role: "tool", output: ["x"] }));
// One arm lacks the member the assertion names: Node's read answers
// undefined for that arm.
interface Stamped {
timestamp?: number;
text: string;
}
interface Unstamped {
text: string;
}
function stamp(entry: Stamped | Unstamped): number | undefined {
return (entry as { timestamp?: number }).timestamp;
}
console.log("stamp", stamp({ timestamp: 5, text: "a" }));
console.log("stamp", stamp({ text: "b" }));
// A discriminant read re-narrows the union it came from.
interface BashOperations {
exec(command: string): string;
}
interface BashResult {
exitCode: number;
}
type BashEventResult =
| { operations: BashOperations; result?: undefined }
| { operations?: undefined; result: BashResult };
function describe(result: BashEventResult | undefined): string {
if (result?.result) return `exit ${result.result.exitCode}`;
return `operations ${typeof result?.operations}`;
}
console.log("event", describe({ result: { exitCode: 3 } }));
console.log("event", describe({ operations: { exec: (command) => command } }));
console.log("event", describe(undefined));
// A narrowed array whose stored union still owns an absent arm.
function firstLength(models: string[] | undefined): number {
if (models) return models.length;
return -1;
}
console.log("first", firstLength(["a", "b"]), firstLength(undefined));
@@ -0,0 +1,58 @@
// A member the arms answer through an ACCESSOR — an inherited getter, or a
// getter a subclass overrides — has no field slot to read, so the read
// dispatches on the union's tag and calls the arm's own getter, once per
// read, as Node does. A data-field arm beside a getter arm reads its slot.
const logs: string[] = [];
class Panel {
items: string[];
constructor(items: string[]) {
this.items = items;
}
get hasOverlayEntries(): boolean {
logs.push("has");
return this.items.length > 0;
}
get count(): number {
logs.push("count");
return this.items.length;
}
}
class MainPanel extends Panel {
mode = "main";
}
class AltPanel extends Panel {
mode = "alt";
get count(): number {
logs.push("alt count");
return this.items.length * 10;
}
}
class FixedPanel {
mode = "fixed";
items: string[];
hasOverlayEntries = true;
constructor(items: string[]) {
this.items = items;
}
get count(): number {
logs.push("fixed count");
return this.items.length + 100;
}
}
function pickPanel(which: "m" | "a", items: string[]): MainPanel | AltPanel {
return which === "m" ? new MainPanel(items) : new AltPanel(items);
}
for (const which of ["m", "a"] as const) {
const panel = pickPanel(which, which === "m" ? ["x"] : []);
console.log("panel", which, panel.mode, panel.hasOverlayEntries, panel.count);
}
function pickMixed(which: "a" | "f", items: string[]): AltPanel | FixedPanel {
return which === "a" ? new AltPanel(items) : new FixedPanel(items);
}
for (const which of ["a", "f"] as const) {
const panel = pickMixed(which, ["y", "z"]);
console.log("mixed", which, panel.mode, panel.hasOverlayEntries, panel.count);
}
console.log("logs", logs.join(","));
+37
View File
@@ -0,0 +1,37 @@
// `.length` on a union of arrays (and strings): every arm answers its own
// length, so the read dispatches on the tag instead of demanding one
// record arm that carries a `length` field.
interface Model {
id: string;
}
interface ModelRef {
ref: string;
}
function ids(models: Model[] | ModelRef[]): number {
return models.length;
}
console.log("models", ids([{ id: "a" }, { id: "b" }]));
console.log("refs", ids([{ ref: "r" }, { ref: "s" }, { ref: "t" }]));
function scalars(values: string[] | number[]): number {
return values.length;
}
console.log("strings", scalars(["a", "b", "c"]));
console.log("numbers", scalars([1, 2]));
function textual(part: string | string[]): number {
return part.length;
}
console.log("text", textual("hello"));
console.log("parts", textual(["a", "b"]));
// The dispatch reads the value ONCE, even when the receiver is a call.
let calls = 0;
function make(fill: string): string[] {
calls++;
return [fill, fill];
}
function measured(part: string | string[]): number {
return part.length;
}
console.log("call", measured(make("q")), "calls", calls);
@@ -0,0 +1,40 @@
// A `keyof` read over a record that holds an `unknown` member: the
// checker's answer collapses to `unknown` (the union of every field type
// absorbs into it), so there is no typed join. The read dispatches per
// declared key through the checked-dynamic slot instead.
interface Retry {
baseDelayMs?: number;
enabled?: boolean;
}
interface Settings {
$schema?: string;
enabledModels?: string[];
branchSummary: unknown;
fullscreenWheelScrollLines?: number | string;
modelThinkingLevels?: { [key: string]: string };
retry?: Retry;
outputPad?: number;
}
function read(settings: Settings, key: keyof Settings): unknown {
return settings[key];
}
const settings: Settings = {
branchSummary: { note: "x", count: 2 },
outputPad: 4,
enabledModels: ["m"],
fullscreenWheelScrollLines: "wide",
modelThinkingLevels: { high: "big" },
retry: { enabled: true },
};
const keys: (keyof Settings)[] = [
"outputPad",
"enabledModels",
"branchSummary",
"retry",
"$schema",
"fullscreenWheelScrollLines",
];
for (const key of keys) {
const value = read(settings, key);
console.log(key, typeof value, value === undefined ? "absent" : JSON.stringify(value));
}
@@ -0,0 +1,50 @@
// A written type predicate usually names FEWER members than the receiver's
// element (`(b): b is { type: "text"; text: string }` over a block that
// also carries `textSignature`): the predicate's record is no arm of the
// element union, so each kept element converts to it by its own arm, tag
// by tag. An arm the predicate's record cannot be read from throws the
// checked TypeError, because a predicate that returned true for it lied.
type Block =
| { type: "image"; data: string; mimeType: string }
| { type: "text"; text: string; textSignature?: string };
const blocks: Block[] = [
{ type: "text", text: "first" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "second", textSignature: "sig" },
];
function texts(list: Block[]): { type: "text"; text: string }[] {
return list.filter((b): b is { type: "text"; text: string } => b.type === "text");
}
console.log("texts", texts(blocks).map((t) => `${t.type}:${t.text}`).join(","));
// The same narrowing through a guard function passed as a value.
function isText(b: Block): b is { type: "text"; text: string } {
return b.type === "text";
}
console.log("guarded", blocks.filter(isText).length);
// Every arm converts when the predicate names members all of them carry:
// each kept element is read field by field into the predicate's record.
interface A {
tag: "a";
n: number;
}
interface B {
tag: "b";
n: number;
}
function numbers(list: (A | B)[]): { tag: "a" | "b"; n: number }[] {
return list.filter((v): v is { tag: "a" | "b"; n: number } => v.n > 0);
}
console.log(
"numbers",
numbers([
{ tag: "a", n: 1 },
{ tag: "b", n: 0 },
{ tag: "a", n: 3 },
])
.map((v) => `${v.tag}${v.n}`)
.join(","),
);
+46
View File
@@ -0,0 +1,46 @@
// A `flatMap` callback whose body is a block and whose enclosing call has a
// contextual array type infers a TUPLE union (`return v ? [pair] : []`
// infers `[] | [pair]`), so one arm is a tuple rather than an array. The
// loop appends a tuple arm's fields in order, exactly as it appends an array
// arm's elements, and an empty arm appends nothing.
interface Namespace {
name: string;
description?: string;
}
interface Tool {
name: string;
label?: string;
}
interface Loadout {
callable: Tool[];
getNamespace(name: string): Namespace | undefined;
}
function pairsOf(loadout: Loadout): (readonly [string, Namespace])[] {
return loadout.callable.flatMap((tool) => {
const namespace = loadout.getNamespace(tool.name);
return namespace ? [[tool.name, namespace] as const] : [];
});
}
const loadout: Loadout = {
callable: [{ name: "docs" }, { name: "skip", label: "S" }, { name: "notes" }],
getNamespace: (name) =>
name === "skip" ? undefined : { name: name.toUpperCase(), description: `${name} ns` },
};
const pairs = pairsOf(loadout);
console.log("pairs", pairs.length);
for (const [key, namespace] of pairs) console.log("pair", key, namespace.name, namespace.description);
console.log("map size", new Map(pairsOf(loadout)).size);
// A two-field tuple arm contributes both fields, in order.
function doubled(words: (string | undefined)[]): string[] {
return words.flatMap((word) => (word ? [word, word.toUpperCase()] : []));
}
console.log("doubled", doubled(["a", undefined, "bc"]).join(","));
// An array arm beside a scalar arm keeps the per-value flatten decision.
function pieces(parts: (string | string[])[]): string[] {
return parts.flatMap((part) => (Array.isArray(part) ? part : [part]));
}
console.log("pieces", pieces(["x", ["y", "z"], "w"]).join("-"));
@@ -0,0 +1,41 @@
// `o || {}` where flow narrowing ruled out the other arm of `o`'s type:
// the stored value keeps its wider union, so the default tests the value in
// its own union and extracts the checker's arm through the checked
// narrowing. The falsy arm takes the default; an object argument is the
// result itself.
interface ExportOptions {
outputPath?: string;
theme?: string;
inlineImages?: boolean;
}
function fromPath(options?: ExportOptions | string): ExportOptions {
return typeof options === "string" ? { outputPath: options } : options || {};
}
const names = (o: ExportOptions): string => `${o.outputPath ?? "-"}/${o.theme ?? "-"}/${o.inlineImages ?? "-"}`;
console.log("none", names(fromPath(undefined)));
console.log("path", names(fromPath("out.html")));
console.log("object", names(fromPath({ outputPath: "p", theme: "dark", inlineImages: true })));
console.log("empty", JSON.stringify(fromPath({})));
// The same shape over a union of records with a default object.
interface Cursor {
line: number;
column: number;
}
function cursorAt(where: Cursor | undefined): Cursor {
return where || { line: 1, column: 1 };
}
console.log("cursor", JSON.stringify(cursorAt(undefined)), JSON.stringify(cursorAt({ line: 4, column: 2 })));
// The default is evaluated only when the left is falsy.
let defaults = 0;
function counted(): Cursor {
defaults++;
return { line: 0, column: 0 };
}
const base: Cursor | undefined = { line: 2, column: 3 };
console.log("lazy", JSON.stringify(base || counted()), defaults);
const missing: Cursor | undefined = undefined;
console.log("fallback", JSON.stringify(missing || counted()), defaults);
@@ -0,0 +1,44 @@
// Array producers whose callback answers `undefined`: the result is an
// `undefined[]`, whose elements are PRESENT undefined values (JSON prints
// them as null, `in` sees them), and the mapper still runs once per index.
type PendingQuery = {
palette: undefined[];
timer: number | undefined;
};
function build(n: number): PendingQuery {
return {
palette: Array.from({ length: n }, () => undefined),
timer: undefined,
};
}
const query = build(3);
console.log(query.palette.length);
console.log(query.palette[0] === undefined, query.palette[2] === undefined);
console.log(0 in query.palette, 2 in query.palette, 3 in query.palette);
console.log(query.timer === undefined);
// The mapper runs once per index, in order.
const seen: number[] = [];
const mapped: undefined[] = Array.from({ length: 4 }, (_value, index) => {
seen.push(index);
return undefined;
});
console.log(mapped.length, seen.join(","));
console.log(mapped.every((value) => value === undefined));
// Array.from over an existing array, and `.map` over an existing array,
// both with a discarding mapper.
const fromArray: undefined[] = Array.from([1, 2, 3], (_n) => undefined);
console.log(fromArray.length, fromArray[0] === undefined);
const discarded: undefined[] = ["a", "b"].map((_text) => undefined);
console.log(discarded.length, discarded[1] === undefined);
// A dense literal of undefined elements keeps the same shape.
const literal: undefined[] = [undefined, undefined];
console.log(literal.length, literal[1] === undefined);
console.log(JSON.stringify(query.palette));
console.log(JSON.stringify(mapped));
@@ -0,0 +1,59 @@
// `new Map()` (no type arguments) in a contextual slot whose type is a
// union: `Map<K, V> | undefined`, `ReadonlyMap<K, V> | null`. A Map value
// can only inhabit the map arm, so the construction adopts that layout
// instead of the checker's bare Map<unknown, unknown>.
function lookup(handlers: Map<string, number> | undefined, key: string): string {
if (handlers === undefined) return "absent";
const found = handlers.get(key);
return found === undefined ? "missing" : String(found);
}
const counts: Map<string, number> | undefined = new Map();
if (counts !== undefined) {
counts.set("a", 1);
counts.set("b", counts.size + 2);
console.log(counts.size, counts.get("a"), counts.get("b"));
}
console.log(lookup(counts, "a"), lookup(counts, "z"), lookup(undefined, "a"));
console.log(counts instanceof Map);
// A nullable annotation takes the same arm.
const nullable: Map<string, number> | null = new Map();
console.log(nullable !== null, nullable instanceof Map);
// A readonly view announces no writes; the value is still a real Map.
const readonlyView: ReadonlyMap<string, number> | undefined = new Map();
if (readonlyView !== undefined) {
console.log(readonlyView.size, readonlyView.get("missing") === undefined);
}
// The contextual type of an optional record field is the same union.
type Registry = {
handlers?: Map<string, number>;
names?: Set<string>;
};
function createRegistry(): Registry {
return { handlers: new Map(), names: new Set() };
}
const registry = createRegistry();
if (registry.handlers !== undefined) {
registry.handlers.set("on", 7);
console.log(registry.handlers.size, registry.handlers.get("on"));
}
if (registry.names !== undefined) {
registry.names.add("first");
console.log(registry.names.size, registry.names.has("first"));
}
// A parameter slot infers the same way for Map and Set.
function describe(map: Map<string, number> | undefined, set: Set<string> | undefined): string {
return `${map === undefined ? "-" : map.size}/${set === undefined ? "-" : set.size}`;
}
console.log(describe(new Map(), new Set()), describe(undefined, undefined));
// Iteration and JSON-free surfaces keep the element types.
const entries = new Map([["x", 1], ["y", 2]] as [string, number][]);
console.log([...entries.keys()].join(","), [...entries.values()].join(","));
@@ -0,0 +1,42 @@
// A comparator may declare more parameters than `sort` passes: the surplus
// positions receive the native undefined value, exactly as in a two-argument
// call. The imported `(a, b, loose) => …` comparators and lambdas with an
// optional or defaulted trailing parameter take this shape, and a lambda
// whose own signature already spells its completed ABI is a value wherever
// its slot converts.
type Comparer = (a: string, b: string, loose?: boolean) => number;
const seen: string[] = [];
const cmp: Comparer = (a, b, loose) => {
seen.push(loose === undefined ? "absent" : String(loose));
return a < b ? -1 : a > b ? 1 : 0;
};
const versions = ["1.9.0", "1.10.0", "1.2.0"];
console.log([...versions].sort(cmp).join(","));
console.log(seen.length > 0 && seen.every((entry) => entry === "absent"));
console.log(versions.join(","));
// `toSorted` adapts the same way and leaves the receiver untouched.
console.log(versions.toSorted(cmp).join(","), versions.join(","));
// Direct calls through the value complete the same parameters.
console.log(cmp("a", "b"), cmp("b", "a"), cmp("a", "a", true));
// An inline comparator with an optional surplus parameter sorts by number.
const nums = [3, 1, 2];
const descending = (a: number, b: number, dir?: boolean): number =>
dir === undefined ? b - a : a - b;
console.log(nums.toSorted(descending).join(","));
console.log([...nums].sort((a, b, dir?: boolean) => (dir ? a - b : b - a)).join(","));
console.log(nums.join(","));
// A defaulted surplus parameter keeps its default when sort omits it.
const withDefault = (a: string, b: string, scale = 1): number =>
(a < b ? -1 : a > b ? 1 : 0) * scale;
console.log(["b", "a", "c"].sort(withDefault).join(""));
console.log(withDefault("a", "b"), withDefault("a", "b", 3));
// A one-parameter comparator is still a prefix of the slot.
console.log(["d", "c", "a"].sort((left) => left.charCodeAt(0) - 97).join(","));
+11
View File
@@ -0,0 +1,11 @@
// A flatMap callback whose tuple arm has more than ten elements appends
// them in index order.
function spread(values: number[]): number[] {
return values.flatMap((n) => {
const base = n * 100;
return n
? ([base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11] as const)
: [];
});
}
console.log(spread([1, 0, 2]).join(","));
@@ -0,0 +1,54 @@
// A generic function reads OPTIONAL members its constraint declares while the
// call site's own options record omits them (`options.sessionId` under an
// `"sessionId" in options` guard). Node reads the property off the object it
// actually has, so a caller that omits the member answers undefined and one
// that supplies it answers the value — for every instantiation of the
// function, which the program pins one by one.
interface RequestOptions<TModel> {
model?: TModel;
apiKey?: string;
sessionId?: string;
}
interface Transforms {
transformHeaders?: (headers: Record<string, string | null>) => Record<string, string | null>;
}
function describeOptions<TModel, TOptions extends RequestOptions<TModel> & Transforms>(
options: TOptions | undefined,
): string {
const sessionId =
options && "sessionId" in options && typeof options.sessionId === "string"
? options.sessionId
: "<none>";
const apiKey =
options && "apiKey" in options && typeof options.apiKey === "string" ? options.apiKey : "<no-key>";
return `session=${sessionId} key=${apiKey}`;
}
const withSession = { apiKey: "sk-1", sessionId: "sess-1" };
const withoutSession = { apiKey: "sk-2" };
const onlySession = { sessionId: "sess-3" };
const empty = {};
console.log(describeOptions<{ id: string }, typeof withSession>(withSession));
console.log(describeOptions<{ id: string }, typeof withoutSession>(withoutSession));
console.log(describeOptions<{ id: number }, typeof onlySession>(onlySession));
console.log(describeOptions<{ id: number }, typeof empty>(empty));
console.log(describeOptions<{ id: string }, Transforms>(undefined));
// The same read through a generic method on a class, whose instantiation is
// fixed by the call site's own argument type.
class OptionsReader<TOptions extends RequestOptions<string> & Transforms> {
read(options: TOptions | undefined): string {
return options && "sessionId" in options && typeof options.sessionId === "string"
? options.sessionId
: "<none>";
}
}
const keyedReader = new OptionsReader<typeof withSession>();
const bareReader = new OptionsReader<typeof withoutSession>();
console.log(
keyedReader.read(withSession),
bareReader.read(withoutSession),
bareReader.read(undefined),
);
@@ -0,0 +1,16 @@
// A generic options bag read through its constraint at a call site whose
// literal omits the member: `??`, `===`, `!` and `typeof` read the
// property, which Node answers as undefined.
interface Options {
key?: string;
label?: string;
}
function describe<O extends Options>(options: O): string {
const a = options.key ?? "fallback";
const b = options.key === undefined;
const c = !options.key;
const d = typeof options.key;
return `${a} ${b} ${c} ${d}`;
}
console.log(describe({ label: "x" }));
console.log(describe({ key: "k", label: "y" }));
+38
View File
@@ -0,0 +1,38 @@
// `return fatal(...)` where `fatal` is declared `Promise<never>`: the promise
// can never settle, so the awaited value is unreachable and fills the async
// function's record result instead of failing a void-to-record conversion.
interface Outcome {
cancelled: boolean;
}
async function fatal(message: string): Promise<never> {
throw new Error(message);
}
async function run(flag: boolean): Promise<Outcome> {
try {
if (flag) throw new Error(`zap ${flag}`);
return { cancelled: false };
} catch (error: unknown) {
return fatal(error instanceof Error ? error.message : "unknown");
}
}
async function passthrough(): Promise<number> {
try {
throw new Error("always");
} catch {
return fatal("passthrough");
}
}
run(false)
.then((outcome) => console.log("resolved:", outcome.cancelled))
.catch((error) => console.log("rejected:", error instanceof Error ? error.message : error));
run(true)
.then((outcome) => console.log("resolved:", outcome.cancelled))
.catch((error) => console.log("rejected:", error instanceof Error ? error.message : error));
passthrough()
.then((value) => console.log("value:", value))
.catch((error) => console.log("rejected:", error instanceof Error ? error.message : error));
+109
View File
@@ -0,0 +1,109 @@
// A record field holding a function adapts when the slot's function type is a
// structural width of the field's: a parameter record whose `unknown` members
// gain shapes (the checked-dynamic extraction), a callback parameter whose
// record widens the same way one level down, and a promise payload that widens.
//
// `wrapToolDefinition` states the definition's state and details parameters as
// `any`/default `unknown`, so passing a concrete definition crosses the
// `execute` and `renderCall` fields of the record.
interface Truncation {
content: string;
truncated: boolean;
}
interface ToolResult<D> {
content: { type: string; text: string }[];
details: D;
isError: boolean | undefined;
}
type UpdateCallback<D> = (partial: ToolResult<D>) => void;
interface ToolDefinition<S, D> {
name: string;
execute(id: string, onUpdate: UpdateCallback<D> | undefined): Promise<ToolResult<D>>;
renderCall(context: { state: S; label: string }): string;
}
interface BashState {
startedAt: number;
executionStarted: boolean;
}
interface BashDetails {
truncation: Truncation | undefined;
fullOutputPath: string | undefined;
}
function wrapToolDefinition<D = unknown>(
definition: ToolDefinition<any, D>,
): ToolDefinition<any, D> {
return {
name: definition.name,
execute: definition.execute,
renderCall: definition.renderCall,
};
}
function bashDefinition(name: string): ToolDefinition<BashState, BashDetails> {
return {
name,
execute: (id, onUpdate) => {
const result: ToolResult<BashDetails> = {
content: [{ type: "text", text: `ran ${id}` }],
details: { truncation: { content: "tail", truncated: true }, fullOutputPath: undefined },
isError: undefined,
};
onUpdate?.(result);
return Promise.resolve(result);
},
renderCall: (context) => `${context.label} after ${context.state.startedAt}ms`,
};
}
// The `execute` field: the slot's update callback takes `details: unknown`
// where the value's takes the concrete record, and the result promise payload
// widens the same way.
function widenDetails(): ToolDefinition<any, unknown> {
return wrapToolDefinition<unknown>(bashDefinition("bash"));
}
// The `renderCall` field: the slot's context record has `state: unknown`
// where the value's has the concrete state record.
function widenState(definition: ToolDefinition<BashState, BashDetails>): ToolDefinition<any, BashDetails> {
return wrapToolDefinition<BashDetails>(definition);
}
// The same crossing when the argument is an object literal carrying optional
// members the slot's shape also declares.
interface WithHints {
annotations: { readOnlyHint: boolean | undefined } | undefined;
defaultActive: boolean | undefined;
}
function widenLiteral(): ToolDefinition<any, BashDetails> {
const hints: WithHints = { annotations: { readOnlyHint: true }, defaultActive: undefined };
const definition: ToolDefinition<BashState, BashDetails> & WithHints = {
...bashDefinition("literal"),
...hints,
};
console.log("hints:", definition.annotations?.readOnlyHint, definition.defaultActive);
return wrapToolDefinition<BashDetails>(definition);
}
const widened = widenDetails();
widened
.execute("ls", (partial) => {
console.log("update:", partial.content[0]?.text, partial.isError);
})
.then((result) => {
console.log("result:", result.details, result.isError);
});
const rendered = widenState(bashDefinition("grep"));
console.log("render:", rendered.renderCall({ state: { startedAt: 12, executionStarted: true }, label: "grep" }));
const literal = widenLiteral();
console.log("literal render:", literal.renderCall({ state: { startedAt: 3, executionStarted: false }, label: "ls" }));
console.log("name:", literal.name);
@@ -0,0 +1,48 @@
// A discriminated union held across an `await`: the early `stored?.type ===
// "oauth"` return narrows the checker's `stored` to the api-key arm, but the
// runtime-optional storage still holds both arms. The later `s?.type` reads
// dispatch on the stored value's tag, so every credential shape answers as in
// Node — including the arm the earlier guard ruled out at runtime.
type ProviderEnv = Record<string, string>;
interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: ProviderEnv;
}
interface OAuthCredentials {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
}
interface OAuthCredential extends OAuthCredentials {
type: "oauth";
}
type Credential = ApiKeyCredential | OAuthCredential;
const stored: (Credential | undefined)[] = [
{ type: "api_key", key: "sk-live", env: { REGION: "eu" } },
{ type: "oauth", refresh: "r1", access: "a1", expires: 42 },
{ type: "api_key" },
undefined,
];
function load(i: number): Promise<Credential | undefined> {
return Promise.resolve(stored[i]);
}
async function describe(i: number): Promise<string> {
const found = await load(i);
if (found?.type === "oauth") {
return found.expires > 10 ? `oauth ${found.refresh}/${found.access}` : "oauth expired";
}
const credential = found?.type === "api_key" ? found : undefined;
const key = credential?.type === "api_key" ? (credential.key ?? "<none>") : "<missing>";
const region = credential?.env?.REGION ?? "<no-env>";
return `api ${key} ${region}`;
}
async function main(): Promise<void> {
for (let i = 0; i < stored.length; i++) console.log(i, await describe(i));
}
main();
@@ -7,15 +7,6 @@ record-width.ts:12:25 - error SC2002: record shapes must match exactly or width-
hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead
record-width.ts:26:20 - error SC2002: record shapes must match exactly or width-coerce: expected '{ m: ({ a: number; b: number }) => void }', got '{ extra: number; m: ({ a: number }) => void }' — field 'm': '({ a: number }) => void' does not lift into '({ a: number; b: number }) => void'
25 | };
26 | const dstM: DstM = srcM;
| ^~~~
27 | console.log(dstM);
hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead
record-width.ts:27:13 - error SC1090: console.log of '{ m: ({ a: number; b: number }) => void }' values (function values have no inspect lowering (no runtime name exists — Node prints '[Function: name]'; pass the function's declared identifier directly to util.inspect for the baked form)) is not supported yet
26 | const dstM: DstM = srcM;
@@ -0,0 +1 @@
{ "plain": 1600, "san": 2900 }
@@ -0,0 +1 @@
{ "plain": 2200, "san": 2500 }
@@ -0,0 +1 @@
{ "plain": 1400, "san": 2400 }
@@ -0,0 +1 @@
{ "plain": 1300, "san": 2700 }
@@ -0,0 +1 @@
{ "plain": 1300, "san": 2800 }
@@ -0,0 +1 @@
{ "plain": 1500, "san": 2400 }
@@ -0,0 +1 @@
{ "plain": 1450, "san": 2350 }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 3600 }
@@ -0,0 +1 @@
{ "plain": 3100, "san": 3700 }
@@ -0,0 +1 @@
{ "plain": 3100, "san": 3700 }
@@ -0,0 +1 @@
{ "plain": 1455, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 2500, "san": 3400 }
@@ -0,0 +1 @@
{ "plain": 1603, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 2900, "san": 4100 }
@@ -0,0 +1 @@
{ "plain": 3050, "san": 4250 }
@@ -0,0 +1 @@
{ "plain": 2200, "san": 2300 }