mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
pi wave 5: union layouts and reads, provider and tool records (#2547)
This commit is contained in:
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8520-union-narrowed-receiver-reads.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8521-union-accessor-member-read.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8522-union-array-length.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8523-settings-keyof-dynamic-read.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8524-filter-predicate-subset-record.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8525-flatmap-tuple-arms.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8526-union-logical-default.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8527-array-callbacks-undefined-elements.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8528-new-collection-contextual-union.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8529-sort-comparator-surplus-params.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/flatmap-long-tuple-arms.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-constraint-member-read.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-omitted-member-reads.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/promise-never-return.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/record-func-field-width.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -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(","));
|
||||
@@ -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(","),
|
||||
);
|
||||
@@ -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(","));
|
||||
@@ -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" }));
|
||||
@@ -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));
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user