pi conversions and layouts: class/collection/error views, merged promise arms, recursive checked records (#2486)

This commit is contained in:
Chris Tate
2026-10-28 21:52:11 -05:00
committed by GitHub
40 changed files with 1459 additions and 61 deletions
@@ -441,6 +441,17 @@ export function lowerPromiseStaticCall(
inner.kind === "void"
? { kind: "promise", inner: VOID }
: lowerer.mapTypeOf(lowerer.typeOf(call));
// An unannotated JavaScript parameter (`Promise.all(items)` in a
// package's .js file) types the call as Promise<any[]>, which has no
// typed layout: the engine's Promise.all runs over the entries as
// checked-dynamic values, as it does for an array literal there.
if (
resultT === null &&
isJsSourceFile(ts.getSourceFileOfNode(call)) &&
lowerer.dynConvertible(entries.type)
) {
return checkedPromiseAll(lowerer, lowerer.coerceToExpected(entries, DYN), loc);
}
// Promise<T | undefined>[] combines into a `(T | undefined)[]`, which
// stores T elements (primitiveElement): collect the values, then copy
// them in with their present undefined values.
@@ -190,6 +190,9 @@ function protocolSignature(
): ProtocolSignature | null {
// A guarded optional step's receiver still carries its nullish arms.
const recvTs = lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression));
// A record view of a Map or Set has no stored member to read: the call
// dispatches on the collection at runtime instead.
if (lowerer.classCollectionView(recvTs)) return null;
const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text);
if (!property) return null;
const declarations = property.declarations;
@@ -13,6 +13,11 @@ export class ClassProtocols {
/** The value types of the string-keyed dictionaries that keep checked
* identity (see markDictionaryViews). */
private readonly dictionaryViews = new Set<ts.Type>();
/** The structural types a lib Map or Set flows into while it answers one
* of their members (see markIdentityFlows). They keep checked identity
* without protocol dispatch: their members are invoked on the collection
* itself, not read from a property bag. */
private readonly collectionViews = new Set<ts.Symbol>();
constructor(
private readonly checker: ts.TypeChecker,
@@ -425,9 +430,16 @@ export class ClassProtocols {
const added = [...marked].filter((symbol) => !before.has(symbol));
// A copy of an instance drops the methods its class defines, so a view
// that exposes one of them stays live wherever such an instance may
// arrive.
// arrive. A record view of a collection needs no protocol: its checked
// identity is the collection itself, and its members are the
// collection's own methods, which protocol dispatch would read from a
// property bag instead of invoking.
const kept = new Set(
added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
added.filter(
(symbol) =>
!this.collectionViews.has(symbol) &&
(methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
),
);
let grew = true;
while (grew) {
@@ -501,8 +513,9 @@ export class ClassProtocols {
}
// A Pick or Omit view's values are instances of the class it views,
// or of any other program class that has the view's members.
if (this.pickOfProgramClass(type)) {
add(type.getAliasTypeArguments()[0]!);
const viewed = this.pickViewOf(type, 0);
if (viewed !== null) {
add(viewed);
for (const other of this.classesAcceptedBy(type))
for (const related of this.classFamily(other, facts)) out.add(related);
return;
@@ -634,6 +647,14 @@ export class ClassProtocols {
);
}
/** Whether one of `names` is a member the collection type itself
* declares (`has`, `get`, `size`, `keys`, ...). */
private exposesCollectionMember(collection: ts.Type, names: ReadonlySet<string>): boolean {
return this.checker
.getPropertiesOfType(collection)
.some((property) => names.has(ts.symbolName(property)));
}
/** Whether class `symbol` declares a get or set accessor for one of
* `names`. */
private hasAccessorFor(symbol: ts.Symbol, names: ReadonlySet<string>): boolean {
@@ -698,15 +719,67 @@ export class ClassProtocols {
/** `Pick<C, K>` or `Omit<C, K>` over a program class instance type: a
* view of the class's own members, spelled inline instead of as an
* interface. Like an interface the class matches, a view that exposes a
* method needs a live receiver (see protocol). */
* method needs a live receiver (see protocol). A program type alias of
* such a view names the same view (`type V = Pick<C, K>`), so its
* declaration is resolved to the Pick/Omit it spells. */
private pickOfProgramClass(type: ts.Type): boolean {
return this.pickViewOf(type, 0) !== null;
}
/** The program class instance this view views, resolving program type
* aliases of views (`type A = B`, `type B = Pick<C, K>`) up to a small
* depth; null for everything else. */
private pickViewOf(type: ts.Type, depth: number): ts.Type | null {
if (depth > 8) return null;
const alias = type.getAliasSymbol();
if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit"))
return false;
if (!alias.declarations.every((declaration) => declaration.getSourceFile().isDeclarationFile))
return false;
const source = type.getAliasTypeArguments()[0];
return source !== undefined && this.isProgramClassInstance(source);
if (alias === undefined) return null;
if (this.isPickOrOmitAlias(alias)) {
const source = type.getAliasTypeArguments()[0];
return source !== undefined && this.isProgramClassInstance(source) ? source : null;
}
const declaration = this.programTypeAlias(alias);
return declaration === null ? null : this.pickViewNode(declaration.type, depth + 1);
}
/** The class instance a view's type node views. The resolved type of a
* program alias is the alias itself, so the reference is read by name:
* `Pick<C, K>` / `Omit<C, K>` keep the class it views, and another alias
* is followed through its declaration. */
private pickViewNode(node: ts.TypeNode, depth: number): ts.Type | null {
if (depth > 8) return null;
while (ts.isParenthesizedTypeNode(node)) node = node.type;
if (!ts.isTypeReferenceNode(node)) return null;
const symbol = attempt(() => this.checker.getSymbolAtLocation(node.typeName));
if (symbol === undefined) return null;
if (this.isPickOrOmitAlias(symbol)) {
const sourceNode = node.typeArguments?.nodes[0];
const source =
sourceNode === undefined
? undefined
: attempt(() => this.checker.getTypeFromTypeNode(sourceNode));
return source !== undefined && this.isProgramClassInstance(source) ? source : null;
}
const declaration = this.programTypeAlias(symbol);
return declaration === null ? null : this.pickViewNode(declaration.type, depth + 1);
}
/** The lib's `Pick` or `Omit` utility type. */
private isPickOrOmitAlias(symbol: ts.Symbol): boolean {
const name = ts.symbolName(symbol);
if (name !== "Pick" && name !== "Omit") return false;
return symbol.declarations.every(
(declaration) => declaration.getSourceFile().isDeclarationFile,
);
}
/** The program's own declaration of a type alias, if the symbol has one. */
private programTypeAlias(symbol: ts.Symbol): ts.TypeAliasDeclaration | null {
const declaration = symbol.declarations.find(
(candidate) =>
ts.isTypeAliasDeclaration(candidate) &&
!ts.getSourceFileOfNode(candidate).isDeclarationFile,
);
return declaration !== undefined && ts.isTypeAliasDeclaration(declaration) ? declaration : null;
}
/** String-keyed dictionaries (`Record<string, V>`, `{ [key: string]: V }`)
@@ -866,6 +939,58 @@ export class ClassProtocols {
.find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type;
}
/** A lib Map, Set, ReadonlyMap or ReadonlySet: a live collection. Handed
* to a structural type that spells one of its members, a copy would answer
* from its own fields and miss later writes to the collection, so the view
* stays the collection itself (checked-dynamic identity). */
private isBuiltinCollection(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
const target = type.isTypeReference() ? type.getTarget() : type;
const symbol = target?.getSymbol() ?? type.getSymbol();
if (symbol === undefined) return false;
const name = ts.symbolName(symbol);
if (name !== "Map" && name !== "Set" && name !== "ReadonlyMap" && name !== "ReadonlySet")
return false;
return (
(symbol.flags & ts.SymbolFlags.Class) === 0 &&
symbol.declarations.length > 0 &&
symbol.declarations.every(
(declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile,
)
);
}
/** A builtin collection with an `any` or `unknown` type argument is
* UNTYPED (`new Map()`). It cannot back a structural view narrower than
* the values it may yield — `get` answers `any`, which the view declares
* as its own member type — so it keeps the record-shape refusal rather
* than carrying identity into the view. */
private isUntypedCollection(type: ts.Type): boolean {
if (!type.isTypeReference()) return true;
const args = this.checker.getTypeArguments(type);
return (
args.length === 0 ||
args.some((arg) => (arg.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0)
);
}
/** A lib Error, TypeError, ... instance type (RUNTIME_ERROR_CLASSES' lib
* names). Its fields are writable and its checked-dynamic view is the
* error itself, so a record built from it would miss later writes. */
private isBuiltinError(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
const symbol = type.getSymbol();
if (symbol === undefined) return false;
if (!ERROR_LIB_NAMES.has(ts.symbolName(symbol))) return false;
return (
(symbol.flags & ts.SymbolFlags.Class) === 0 &&
symbol.declarations.length > 0 &&
symbol.declarations.every(
(declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile,
)
);
}
private isProgramClassInstance(type: ts.Type): boolean {
const symbol = type.getSymbol();
return (
@@ -888,6 +1013,14 @@ export class ClassProtocols {
return element !== undefined && this.carriesIdentity(element, marked);
}
if (this.isProgramClassInstance(type)) return true;
// A Map or Set is a live collection: a record built from it answers the
// members it copies once and misses every later write. An untyped
// `new Map()` cannot back a narrower view (see isUntypedCollection), so
// only a fully typed collection carries identity into the view.
if (this.isBuiltinCollection(type)) return !this.isUntypedCollection(type);
// A builtin Error keeps its own identity too: extra fields the program
// puts on it (a status, a body) belong to the error, not to a copy.
if (this.isBuiltinError(type)) return true;
// A live Pick or Omit view of a program class holds the instance itself.
if (this.pickOfProgramClass(type) && this.usesCheckedIdentity(type)) return true;
const symbol = type.getSymbol();
@@ -959,7 +1092,27 @@ export class ClassProtocols {
const from = source.getSymbol();
if (from === symbol) return false;
const facts = this.facts;
let collectionGrew = false;
if (facts !== null) {
// A Map or Set that reaches the view, which answers one of its
// members: a record copy has no live backing behind `has`, `get` or
// `size`, and Node's answer changes with every later write. The view
// keeps checked identity, but not protocol dispatch: its members are
// the collection's own methods. A collection view's value handed on
// to another structural type is the same collection, so that view
// answers through it too.
const collection = this.isBuiltinCollection(source)
? this.exposesCollectionMember(
source,
new Set(
this.checker.getPropertiesOfType(target).map((property) => ts.symbolName(property)),
),
)
: from !== undefined && this.collectionViews.has(from);
if (collection && !this.collectionViews.has(symbol)) {
this.collectionViews.add(symbol);
collectionGrew = true;
}
// The flow: the source's classes and marked type reach `symbol`.
const origins = facts.origins.get(symbol) ?? new Set<ts.Symbol>();
for (const origin of this.originsOf(source, facts)) origins.add(origin);
@@ -971,11 +1124,25 @@ export class ClassProtocols {
facts.flows.set(from, flows);
}
}
if (marked.has(symbol)) return false;
// A view marked earlier that becomes a collection view still changes
// the fixpoint: the views its values flow into are asked again.
if (marked.has(symbol)) return collectionGrew;
marked.add(symbol);
return true;
}
/** Whether `type` is a structural view that a Map or Set reaches and that
* answers through the collection's own methods (see markIdentityFlows).
* Such a view keeps checked identity but never protocol dispatch: there
* is no stored member to read, and the collection answers the call. */
collectionView(type: ts.Type): boolean {
this.index();
if (type.isUnionType() || type.isIntersectionType())
return ts.constituentTypes(type).some((part) => this.collectionView(part));
const symbol = type.getSymbol();
return symbol !== undefined && this.collectionViews.has(symbol);
}
/** Class-backed protocols require live receiver dispatch. Other interfaces,
* including factories backed by static methods and ordinary records of
* closures, retain their fixed native layouts. */
@@ -995,9 +1162,12 @@ export class ClassProtocols {
// it (a literal whose methods read `this`) or when it receives existing
// objects (markDictionaryViews).
const dictionaryValue = this.dictionaryValueType(type);
const view = type.getSymbol();
const answer =
this.protocol(type) ||
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue));
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)) ||
// A record view of a Map or Set answers through the collection itself.
(view !== undefined && this.collectionViews.has(view));
this.answers.set(type, answer);
return answer;
}
@@ -1062,6 +1232,22 @@ interface FlowFacts {
/** The empty answer of the symbol maps above. */
const NO_SYMBOLS: ReadonlySet<ts.Symbol> = new Set();
/** The lib names RUNTIME_ERROR_CLASSES gives the runtime error encoding
* (`%Error`, `%TypeError`, ...; ir.ts). */
const ERROR_LIB_NAMES = new Set([
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"DOMException",
"ReferenceError",
"EvalError",
"URIError",
"AggregateError",
"AssertionError",
"SuppressedError",
]);
/** Calls of these namespaces and functions observe whole objects. */
const OBSERVER_NAMESPACES = new Set(["console", "JSON", "Object", "Reflect", "util", "assert"]);
const OBSERVER_FUNCTIONS = new Set([
@@ -114,10 +114,12 @@ export function lowerArrayHofCall(
// leave an element its callback ignores unread.
const unread = callback.unread && !present && elementMayBeForeign(lowerer, elem);
const fnRet = fnArg.type.ret;
if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) {
// The result array U[] is unrepresentable here (no void elements;
// the map helper's closure-returning form has no fixture-backed
// story yet).
if (method === "map" && fnRet.kind === "void") {
// The result array `void[]` is unrepresentable: a void return value is
// erased before it could be stored. A CLOSURE-returning callback is an
// ordinary function element — the value is one ScrClosure for its whole
// life, stored by reference with the element kind's `_v` adapter and
// trace, exactly like a closure array literal (`[() => 1]`).
lowerer.badType(call, lowerer.typeOf(call));
}
if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet);
@@ -659,7 +661,8 @@ export function lowerArrayUnionHofCall(
const argNode = call.arguments.nodes[0]!;
const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT);
const fnRet = fnArg.type.ret;
if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) {
if (method === "map" && fnRet.kind === "void") {
// `void[]` has no element (see the plain-receiver map path).
lowerer.badType(call, lowerer.typeOf(call));
}
if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet);
@@ -10653,6 +10653,83 @@ function isUntypedIterable(lowerer: Lowerer, node: ts.Expression): boolean {
return lowerer.mapTypeOf(type)?.kind === "dyn";
}
/** `for (const x of map.get(k) ?? [])`: the receiver's checker type is one
* non-array iterable beside `never[]` — the array literal's type, whose only
* value is the empty array (`never` is uninhabited), so that arm yields
* nothing at all. The loop walks the iterable arm under its tag test and the
* empty-array arm runs zero iterations. An ARRAY iterable never needs this:
* `T[] | never[]` reduces in the checker to `T[]`, never being assignable to
* T. A union of two inhabited iterables has no single element type and keeps
* its existing fence. */
function lowerForOfEmptyArrayArm(
lowerer: Lowerer,
stmt: ts.ForOfStatement,
iterable: IrExpr,
iterSrc: ts.Expression,
labels?: string[],
): IrStmt | null {
if (iterable.type.kind !== "union" || stmt.awaitModifier !== undefined) return null;
const { checker } = lowerer;
const widened = lowerer.typeOf(iterSrc);
if (!widened.isUnionType()) return null;
const parts = ts.constituentTypes(widened);
if (parts.length !== 2) return null;
const neverArrays = parts.filter((part) => {
if (!checker.isArrayType(part)) return false;
const elem = checker.getTypeArguments(part as ts.TypeReference)[0];
return elem !== undefined && (elem.flags & ts.TypeFlags.Never) !== 0;
});
if (neverArrays.length !== 1) return null;
const other = parts.find((part) => part !== neverArrays[0]);
if (other === undefined) return null;
const armT = lowerer.mapTypeOf(other);
if (armT === null) return null;
if (armT.kind !== "set" && armT.kind !== "map" && armT.kind !== "string") return null;
const tag = lowerer.armTag(iterable.type.unionId, armT);
if (tag < 0) return null;
const loc = locOf(stmt);
// The tag test and the walk read the source once: a call or getter in the
// head runs one time, and the loop walks the value the test saw.
const source = lowerer.declareHiddenLocal("%forOfEmptyArm", iterable.type);
const sourceRef: IrExpr = { kind: "varRef", localId: source.id, type: iterable.type, loc };
const narrowed: IrExpr = {
kind: "unionNarrow",
unionId: iterable.type.unionId,
tag,
value: sourceRef,
type: armT,
loc,
};
const loop: IrStmt =
armT.kind === "set"
? lowerForOfSet(lowerer, stmt, narrowed, armT)
: armT.kind === "map"
? lowerForOfMap(lowerer, stmt, narrowed, armT)
: lowerForOfString(lowerer, stmt, narrowed, labels);
return {
kind: "block",
body: [
{ kind: "varDecl", localId: source.id, init: iterable, loc },
{
kind: "if",
cond: {
kind: "unionIsTag",
unionId: iterable.type.unionId,
tag,
negated: false,
value: sourceRef,
type: BOOL,
loc,
},
then: [loop],
else_: null,
loc,
},
],
loc,
};
}
export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
// Labels consume HERE (nested statements must never see them). The
// plain array/string paths carry them; the container/matchAll/stdin
@@ -10936,6 +11013,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
const iterableUnion = iterableUnionOf(lowerer, iterable.type);
if (iterableUnion) return lowerForOfIterable(lowerer, stmt, iterable, iterableUnion, labels);
}
{
const emptyArrayArm = lowerForOfEmptyArrayArm(lowerer, stmt, iterable, iterSrc, labels);
if (emptyArrayArm) return emptyArrayArm;
}
let tupleSource: {
shapeId: string;
fields: { name: string; type: IrType }[];
@@ -5127,6 +5127,13 @@ export class Lowerer {
return this.classProtocols.usesCheckedIdentity(type);
}
/** A record view a Map or Set answers: it keeps protocol identity but
* calls the collection's own methods, never a stored member. */
classCollectionView(type: ts.Type): boolean {
this.classProtocols ??= new ClassProtocols(this.checker, this.moduleOrder);
return this.classProtocols.collectionView(type);
}
/** Declared class fields may store a DYN value only when its static type
* is `unknown` or `object` (also with `null` or `undefined`, or an array
* of those), names checked protocol identity, is an owned runtime
+106 -9
View File
@@ -5419,25 +5419,46 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
) {
return DYN;
}
// SEVERAL PROMISE ARMS are ONE: `Promise<A> | Promise<B>` is
// `Promise<A | B>`. A promise arm's tag IS the `instanceof Promise`
// test, so JavaScript cannot tell the arms apart at run time (both
// answer true, both `await` to a value of their payload), and the
// merged payload union is exactly what `await` produces either way.
// The merge happens here, in the union the checker gave us, so every
// use site (a declaration slot, an argument, an `await`, `.then`)
// sees the same one-promise-arm layout; the conversion of a
// `Promise<A>` value into it is the checked promise-payload widening
// (promisePayloadWidens / promiseWiden). A payload that cannot ride
// that union (a promise of a promise, a regex or generator payload)
// keeps the whole union fenced.
const mergedArms = mergePromiseArms(arms, ctx);
if (mergedArms === null) return null;
// Promise arms were the whole union (`Promise<void> | Promise<undefined>`
// merges to the plain `Promise<void>`): a one-armed union has no
// representation and needs none — the merge left the arm alone.
if (mergedArms.length === 1) {
if (unions.recursivePending(widened)) return null;
return mergedArms[0]!;
}
if (
arms.some(
mergedArms.some(
(a) =>
a.kind === "void" ||
a.kind === "union" ||
a.kind === "dyn" ||
// A generator arm only comes from an Iterable<T> (an optional
// one keeps its unit arms beside the iterable union's).
(a.kind === "generator" && iterableArmsElem(arms) === null),
(a.kind === "generator" && iterableArmsElem(mergedArms) === null),
) ||
!maybePromiseUnionOk(parts, arms, ctx)
!maybePromiseUnionOk(parts, mergedArms, ctx)
) {
return null;
}
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
mergedArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
const discriminant =
unionDiscriminant(recordParts, arms, ctx) ??
unionDiscriminant(recordParts, mergedArms, ctx) ??
(substitutedUnions.length === 1
? remapUnionDiscriminant(substitutedUnions[0]!, arms)
? remapUnionDiscriminant(substitutedUnions[0]!, mergedArms)
: undefined);
if (unions.recursivePending(widened)) {
// The knot closed through this union. A frame that resolved
@@ -5446,9 +5467,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// same ts.Type answers differently per instantiation — so
// recursive generic-open unions stay fenced.
if (contextResolutions !== sensitivityAtEntry) return null;
return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) };
return {
kind: "union",
unionId: unions.finalizeRecursive(widened, mergedArms, discriminant),
};
}
return { kind: "union", unionId: unions.intern(arms, discriminant) };
return { kind: "union", unionId: unions.intern(mergedArms, discriminant) };
} finally {
unions.inProgress.delete(widened);
}
@@ -8871,6 +8895,79 @@ function maybePromiseUnionOk(
return maybePromiseThenableArm(parts, ctx) === null;
}
/** Merge the promise arms of one union into a single `Promise<A | B>`
* (mapTypeInner's union rule): JavaScript-wise the arms are
* indistinguishable — `instanceof Promise` answers true for both, and
* `await` settles to the payload union — so the merged arm is the same
* value in the same slot. Payloads join like union arms: a `void` payload
* becomes the undefined unit, a payload that is already a union flattens,
* and a checked-dynamic payload (`Promise<unknown>`) absorbs the arms the
* checked-dynamic representation holds faithfully (dynSubsumableUnionArm).
* Null keeps the whole union fenced: a promise-of-a-promise payload, a
* payload with no union home (regex, generator), or a dyn payload beside an
* arm whose semantics would degrade. */
function mergePromiseArms(arms: IrType[], ctx: TypeMapperCtx): IrType[] | null {
let promiseArms = 0;
for (const arm of arms) if (arm.kind === "promise") promiseArms++;
if (promiseArms < 2) return arms;
const payloads: IrType[] = [];
let dynamic = false;
for (const arm of arms) {
if (arm.kind !== "promise") continue;
const inner = arm.inner;
if (inner.kind === "void") {
payloads.push(UNDEFINED_T);
continue;
}
if (inner.kind === "dyn") {
dynamic = true;
continue;
}
if (inner.kind === "union") {
const substituted = ctx.unions.get(inner.unionId);
if (!substituted?.arms.length) return null;
payloads.push(...substituted.arms);
continue;
}
payloads.push(inner);
}
if (dynamic) {
if (!payloads.every((arm) => dynSubsumableUnionArm(arm, ctx))) return null;
payloads.length = 0;
payloads.push(DYN);
}
const byKey = new Map<string, IrType>();
for (const arm of payloads) {
// A promise payload has no arm home beside the others: `await` of the
// merged promise would adopt it as a thenable, which no union tag does.
if (arm.kind === "promise") return null;
byKey.set(typeKey(arm), arm);
}
const payload = [...byKey.values()];
if (payload.length === 0) return null;
// Every arm is the undefined unit: the promise never settles a value,
// exactly `Promise<void>`.
if (payload.every((arm) => arm.kind === "undefinedT")) payload.length = 0;
// One payload arm is that arm itself (a regex or a promise payload has a
// promise slot but no union arm); a JOINT payload is a union, so its arms
// need union homes.
const inner: IrType | null =
payload.length === 0
? VOID
: payload.length === 1
? payload[0]!
: payload.every((arm) => armHasUnionHome(arm))
? {
kind: "union",
unionId: ctx.unions.intern(
[...payload].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)),
),
}
: null;
const merged: IrType | null = inner === null ? null : { kind: "promise", inner };
return merged === null ? null : [...arms.filter((arm) => arm.kind !== "promise"), merged];
}
/** The first non-promise constituent of a promise-armed union whose type
* has a `then` member (a thenable data arm), or null. */
function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null {
@@ -9025,7 +9122,7 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx):
}
const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise");
if (promiseArms.length > 1) {
return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`;
return `the union shape is supported, but its promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}') do not merge into one promise (their payloads have no common union layout)`;
}
if (promiseArms.length === 1 && dataArms.length > 1) {
const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func");
+147 -1
View File
@@ -128,7 +128,9 @@ describe("checked records with opaque payloads", () => {
id: "unsafe",
fields: [
{ name: "child", type: { kind: "record", shapeId: "unsafe" } },
{ name: "map", type: { kind: "map", key: STRING, value: F64 } },
// The back-edge answers coinductively; this sibling is what makes
// the shape uncheckable.
{ name: "payload", type: { kind: "caught" } },
],
},
],
@@ -255,6 +257,150 @@ describe("native process stream checked storage", () => {
});
});
describe("native promise checked storage", () => {
test("preserves promise fields, elements and union arms without claiming JSON storage", () => {
const promise: IrType = { kind: "promise", inner: STRING };
const openPromise: IrType = { kind: "promise", inner: DYN };
const settled: IrType = { kind: "promise", inner: { kind: "void" } };
const reload: IrType = { kind: "record", shapeId: "reload" };
const optional: IrType = { kind: "union", unionId: "optionalReload" };
const record = (id: string): IrRecordShape | undefined =>
id === "reload"
? {
id,
fields: [
{ name: "readers", type: F64 },
{ name: "promise", type: promise },
],
}
: undefined;
const union = (id: string): IrUnionDef | undefined =>
id === "optionalReload" ? { id, arms: [reload, { kind: "undefinedT" }] } : undefined;
for (const type of [
promise,
openPromise,
settled,
arrayOf(promise),
{ kind: "map", key: STRING, value: promise } as IrType,
reload,
optional,
arrayOf(optional),
]) {
expect(canConvertToDyn(type, record, union)).toBe(true);
expect(canDynCheckTo(type, record, union)).toBe(true);
expect(isJsonSafeType(type, record, union)).toBe(false);
expect(isJsonStringifySafeType(type, record, union)).toBe(false);
}
// A promise of a value with no dynamic representation stays outside the
// conversion domain, including when it sits in a record field.
const blocked: IrType = { kind: "promise", inner: arrayOf({ kind: "procStream" }) };
expect(canConvertToDyn(blocked, record, union)).toBe(false);
expect(canConvertToDyn(arrayOf(blocked), record, union)).toBe(false);
expect(
canConvertToDyn(
{ kind: "record", shapeId: "blockedReload" },
(id) =>
id === "blockedReload" ? { id, fields: [{ name: "promise", type: blocked }] } : undefined,
() => undefined,
),
).toBe(false);
});
});
describe("recursive checked graphs", () => {
// A context whose method takes an options object whose callback receives
// the same context: the check domain re-enters the record through a
// checked slot, as a callback-options API does.
const records = new Map<string, IrRecordShape>([
[
"context",
{
id: "context",
fields: [
{
name: "fork",
type: {
kind: "func",
params: [STRING, { kind: "union", unionId: "options" }],
ret: { kind: "promise", inner: { kind: "record", shapeId: "result" } },
},
},
],
},
],
[
"options",
{
id: "options",
fields: [
{
name: "withSession",
type: {
kind: "func",
params: [{ kind: "record", shapeId: "context" }],
ret: { kind: "promise", inner: { kind: "void" } },
},
},
],
},
],
["result", { id: "result", fields: [{ name: "cancelled", type: { kind: "bool" } }] }],
[
"unsafeContext",
{
id: "unsafeContext",
fields: [
{
name: "fork",
type: {
kind: "func",
params: [{ kind: "record", shapeId: "unsafeOptions" }],
ret: { kind: "void" },
},
},
],
},
],
[
"unsafeOptions",
{
id: "unsafeOptions",
fields: [
{
name: "withSession",
type: {
kind: "func",
params: [{ kind: "record", shapeId: "unsafeContext" }],
ret: { kind: "void" },
},
},
{ name: "body", type: { kind: "caught" } },
],
},
],
]);
const unions = new Map<string, IrUnionDef>([
[
"options",
{ id: "options", arms: [{ kind: "record", shapeId: "options" }, { kind: "undefinedT" }] },
],
]);
const record = (id: string): IrRecordShape | undefined => records.get(id);
const union = (id: string): IrUnionDef | undefined => unions.get(id);
test("answers a recursion back-edge coinductively in both directions", () => {
const context: IrType = { kind: "record", shapeId: "context" };
expect(canConvertToDyn(context, record, union)).toBe(true);
expect(canDynCheckTo(context, record, union)).toBe(true);
expect(canDynCheckTo({ kind: "union", unionId: "options" }, record, union)).toBe(true);
});
test("a failing field inside the cycle still refuses it", () => {
expect(canDynCheckTo({ kind: "record", shapeId: "unsafeContext" }, record, union)).toBe(false);
expect(canDynCheckTo({ kind: "record", shapeId: "unsafeOptions" }, record, union)).toBe(false);
});
});
describe("checked conversion graph traversal", () => {
test.each([
{ label: "boxing", convert: canConvertToDyn },
+28 -4
View File
@@ -4572,6 +4572,17 @@ function canBoxDynCompositeAt(
return true;
case "func":
return canBoxFuncIntoDyn(t, getRecord, getUnion, visiting);
case "promise":
// Promises box by REFERENCE wherever the walker meets them, nested in
// records, arrays and unions exactly like a bare promise: promise<dyn>
// carries its ScrPromise, a void payload is absent, and any other inner
// converts through the adapter promise toDynHelper emits
// (scr_dyn_new_promise_adapting).
return (
t.inner.kind === "dyn" ||
t.inner.kind === "void" ||
canConvertToDyn(t.inner, getRecord, getUnion, visiting)
);
case "set":
return (
canConvertToDyn(t.elem, getRecord, getUnion, visiting) &&
@@ -4651,6 +4662,13 @@ export function canDynCheckTo(
return result;
}
/** The in-progress key of a checked composite. Marking it for the duration of
* the frame answers a recursion back-edge coinductively, and the wrapper
* keeps it as the success memo once the frame returns true. */
function checkedVisitKey(t: IrType): string {
return `checked:${typeKey(t)}`;
}
/** Reuse completed subgraphs within one conversion query. A failed branch
* invalidates successes that may depend on a coinductive back-edge to it;
* active recursion keys remain until their owning calls unwind. */
@@ -4690,8 +4708,8 @@ function canDynCheckToAt(
if (isDynTypedRefType(t)) return true;
if (t.kind === "array") {
if (t.elem.kind === "procStream") return false;
const key = typeKey(t);
if (visiting.has(key)) return false;
const key = checkedVisitKey(t);
if (visiting.has(key)) return true;
visiting.add(key);
const result = canDynCheckTo(t.elem, getRecord, getUnion, visiting);
visiting.delete(key);
@@ -4700,8 +4718,14 @@ function canDynCheckToAt(
// A codec handle unboxes to the record it holds.
if (textCodecRecordClass(t, getRecord) !== null) return true;
if (t.kind === "record") {
const key = typeKey(t);
if (visiting.has(key)) return false;
// Recursive shapes answer COINDUCTIVELY, like the composite dynFrom
// walk: assume the back-edge checks and verify every reachable field at
// its first visit. A recursion through a checked slot is ordinary in a
// callback-options graph — a method's options object takes a context
// whose methods take the same options — and refusing the back-edge
// would fence types whose every leaf is checkable.
const key = checkedVisitKey(t);
if (visiting.has(key)) return true;
const shape = getRecord(t.shapeId);
if (!shape || shapeHasAccessorSlots(shape)) return false;
visiting.add(key);
@@ -85,3 +85,87 @@ handler.press?.();
`);
expect(kinds["handler"]).toBe("record");
});
test("a named alias of Pick over a class stays a live view", async () => {
const kinds = await globalKinds(`
class Counter {
count = 0;
tick(): number { this.count += 1; return this.count; }
peek(): number { return this.count; }
}
type Tickable = Pick<Counter, "tick">;
const counter = new Counter();
const view: Tickable = counter;
console.log(view.tick(), counter.count);
`);
expect(kinds["view"]).toBe("dyn");
expect(kinds["counter"]).toBe("object");
});
test("a named alias of Omit over a class stays a live view", async () => {
const kinds = await globalKinds(`
class Counter {
count = 0;
tick(): number { this.count += 1; return this.count; }
peek(): number { return this.count; }
}
type Peekable = Omit<Counter, "tick">;
const counter = new Counter();
const view: Peekable = counter;
console.log(view.peek());
`);
expect(kinds["view"]).toBe("dyn");
});
test("an alias of an alias of Pick over a class stays a live view", async () => {
const kinds = await globalKinds(`
class Counter {
count = 0;
tick(): number { this.count += 1; return this.count; }
}
type Tickable = Pick<Counter, "tick">;
type AlsoTickable = Tickable;
const counter = new Counter();
const view: AlsoTickable = counter;
console.log(view.tick());
`);
expect(kinds["view"]).toBe("dyn");
});
test("a Map received as a record with one of its members stays the collection", async () => {
const kinds = await globalKinds(`
type Exists = { has(key: string): boolean };
const byId = new Map<string, number>();
const view: Exists = byId;
console.log(view.has("a"), byId.has("a"));
`);
expect(kinds["view"]).toBe("dyn");
});
test("a Set received as a record with one of its members stays the collection", async () => {
const kinds = await globalKinds(`
type Exists = { has(key: string): boolean };
const seen = new Set<string>();
const view: Exists = seen;
console.log(view.has("a"), seen.has("a"));
`);
expect(kinds["view"]).toBe("dyn");
});
test("an interface that extends Error received from an error stays the error", async () => {
const kinds = await globalKinds(`
interface HttpError extends Error {
status: number | undefined;
body: string;
}
function fail(): HttpError {
const error = new Error("x") as HttpError;
error.status = 500;
error.body = "b";
return error;
}
const failure = fail();
console.log(failure.status, failure instanceof Error);
`);
expect(kinds["failure"]).toBe("dyn");
});
@@ -0,0 +1,116 @@
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";
import type { IrModule } from "../src/ir/ir.js";
/** Lower one program to IR; a fence answers its diagnostic codes instead. */
async function lower(
source: string,
): Promise<{ ok: true; module: IrModule } | { ok: false; codes: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-iterable-union-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) };
return { ok: true, module: deserializeModule(await readFile(outPath, "utf8")) };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** Every IR node kind present in the module, as a set. */
function nodeKinds(module: IrModule): Set<string> {
const kinds = new Set<string>();
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (value === null || typeof value !== "object") return;
const node = value as { kind?: unknown };
if (typeof node.kind === "string") kinds.add(node.kind);
for (const child of Object.values(value)) visit(child);
};
visit(module.functions);
return kinds;
}
test("a for-of over an iterable beside never[] walks the iterable on its tag", async () => {
const result = await lower(`
function setTotal(registry: Map<string, Set<string>>, key: string): number {
let total = 0;
for (const name of registry.get(key) ?? []) total += name.length;
return total;
}
function mapTotal(registry: Map<string, Map<string, number>>, key: string): number {
let total = 0;
for (const [name, value] of registry.get(key) ?? []) total += name.length + value;
return total;
}
function stringTotal(registry: Map<string, string>, key: string): number {
let total = 0;
for (const ch of registry.get(key) ?? []) total += ch.length;
return total;
}
console.log(setTotal(new Map(), "k"), mapTotal(new Map(), "k"), stringTotal(new Map(), "k"));
export {};
`);
if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`);
expect(validateModule(result.module)).toEqual([]);
const kinds = nodeKinds(result.module);
// The loop is guarded by the arm's tag; the empty-array arm yields nothing.
expect(kinds.has("unionIsTag")).toBe(true);
expect(kinds.has("unionNarrow")).toBe(true);
});
test("a for-of over never[] beside an effectful receiver evaluates it once", async () => {
const result = await lower(`
function registry(): Map<string, Set<string>> | undefined {
return new Map();
}
function total(): number {
let sum = 0;
for (const name of registry() ?? []) sum += name.length;
return sum;
}
console.log(total());
export {};
`);
if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`);
expect(validateModule(result.module)).toEqual([]);
// The tag test and the walk share ONE evaluation: the receiver is a call,
// not a plain read, so it lands in a hidden local both reads use.
let calls = 0;
const count = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) count(item);
return;
}
if (value === null || typeof value !== "object") return;
const node = value as { kind?: unknown; callee?: unknown };
if (node.kind === "call" && node.callee === "registry") calls++;
for (const child of Object.values(value)) count(child);
};
count(result.module.functions);
expect(calls).toBe(1);
});
test("a union of two inhabited iterables still has no common iteration", async () => {
const result = await lower(`
function total(registry: Map<string, Set<string> | string[]>, key: string): number {
let sum = 0;
for (const item of registry.get(key) ?? []) sum += item.length;
return sum;
}
console.log(total(new Map(), "k"));
export {};
`);
// Both arms can hold values, and the two walks yield at different element
// kinds through different protocols: the union keeps its fence.
expect(result.ok).toBe(false);
if (!result.ok) expect(result.codes).toEqual(["SC2001"]);
});
@@ -0,0 +1,74 @@
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";
import type { IrModule } from "../src/ir/ir.js";
/** Lower one program to IR; a fence answers its diagnostic codes instead. */
async function lower(
source: string,
): Promise<{ ok: true; module: IrModule } | { ok: false; codes: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-map-closure-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) };
return { ok: true, module: deserializeModule(await readFile(outPath, "utf8")) };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
test("a map callback answering a closure produces an array of functions", async () => {
const result = await lower(`
interface Entry { source: string; scope: "user" | "project" }
function tasksFor(entries: Entry[]): Array<() => Promise<string>> {
return entries.map((entry) => async () => \`\${entry.scope}:\${entry.source}\`);
}
function readers(entries: Entry[]): (() => string)[] {
return entries.map((entry) => () => entry.source);
}
console.log(tasksFor([]).length, readers([]).length);
export {};
`);
if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`);
expect(validateModule(result.module)).toEqual([]);
// The element kind is the closure type itself: a function value is one
// ScrClosure stored by reference, like a closure array literal.
for (const name of ["tasksFor", "readers"]) {
const fn = result.module.functions.find((candidate) => candidate.name === name);
expect(fn, name).toBeDefined();
expect(fn!.returnType.kind, name).toBe("array");
if (fn!.returnType.kind === "array") expect(fn!.returnType.elem.kind, name).toBe("func");
}
});
test("an inline map result of closures stays inline", async () => {
const result = await lower(`
interface Entry { source: string }
const entries: Entry[] = [{ source: "a" }];
const first = entries.map((entry) => (() => entry.source.length));
console.log(first[0]!());
export {};
`);
if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`);
expect(validateModule(result.module)).toEqual([]);
});
test("a void-returning map callback still has no element to store", async () => {
const result = await lower(`
const items = [1, 2];
const out = items.map((n) => {
console.log(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"]);
});
@@ -77,16 +77,62 @@ test("an equal promise arm stays a plain copy", async () => {
expect(promisePayloadConversions(retag)).toBe(0);
});
test("two destination promise arms keep the conversion fence", async () => {
test("two destination promise arms merge into one widened payload", async () => {
// `Promise<unknown> | Promise<string>` is `Promise<unknown>`: the
// checked-dynamic payload absorbs the faithful one, so the destination is
// the same one-promise-arm union the single-arm case has.
const result = await compileSource(`
function settle(flag: boolean): Promise<void> | undefined {
return flag ? Promise.resolve() : undefined;
}
const ambiguous: Promise<unknown> | Promise<string> | undefined = settle(true);
console.log(ambiguous === undefined);
const merged: Promise<unknown> | Promise<string> | undefined = settle(true);
console.log(merged === undefined);
export {};
`);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.diagnostics.map((d) => d.code)).toContain("SC2003");
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag."));
expect(retag).toHaveLength(1);
expect(promisePayloadConversions(retag)).toBe(1);
});
test("several promise arms settle to one promise of the payload union", async () => {
const result = await compileSource(`
interface Decision { kind: "stop"; note: string }
interface Settled { reason: string }
function pick(n: number): void | Promise<Settled> | Promise<void> | Decision {
if (n === 0) return;
if (n === 1) return Promise.resolve({ reason: "one" });
return { kind: "stop", note: "x" };
}
async function main(): Promise<void> {
const hit = pick(1);
if (hit instanceof Promise) console.log("then", await hit.then((v) => v === undefined));
const decided = await pick(2);
console.log("decision", decided === undefined ? "?" : "note" in decided ? decided.note : "?");
}
void main();
export {};
`);
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const unions = result.module.unions ?? [];
// The IR invariant the merge establishes: no union holds two promise arms
// (the promise arm's tag IS the `instanceof Promise` test).
for (const union of unions)
expect(union.arms.filter((arm) => arm.kind === "promise").length).toBeLessThan(2);
// The declared union is the promise beside the undefined unit.
const armed = unions.filter((union) => union.arms.some((arm) => arm.kind === "promise"));
expect(armed.length).toBeGreaterThan(0);
const promiseArm = armed[0]!.arms.find((arm) => arm.kind === "promise");
expect(promiseArm?.kind).toBe("promise");
// `void | Promise<Settled> | Promise<void>` merges to a payload UNION:
// the settled record beside the undefined unit.
if (promiseArm?.kind === "promise") {
expect(promiseArm.inner.kind).toBe("union");
if (promiseArm.inner.kind === "union") {
const payload = unions.find((union) => union.id === promiseArm.inner.unionId);
expect(payload?.arms.map((arm) => arm.kind).sort()).toEqual(["record", "undefinedT"]);
}
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6955-pick-alias-class-views.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6956-collection-record-views.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6957-error-interface-views.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8417-promise-union-arms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8418-map-closure-elements.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8419-iterable-never-array-arm.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8430-dyn-record-promise-field.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8431-dyn-recursive-checked-record.ts"
],
"diags": []
}
@@ -0,0 +1,61 @@
// A named type alias of `Pick<C, K>` / `Omit<C, K>` over a program class is
// the same view as the inline form: calls through the alias run the
// instance's own methods, so its state is shared with every other reference.
class Session {
reads = 0;
id: string;
constructor(id: string) {
this.id = id;
}
read(): string {
this.reads += 1;
return this.id + ":" + this.reads;
}
reset(): void {
this.reads = 0;
}
count(): number {
return this.reads;
}
}
type Readable = Pick<Session, "read">;
type Resettable = Omit<Session, "read">;
function twice(view: Readable): string {
return view.read() + "/" + view.read();
}
const session = new Session("a");
console.log(twice(session), session.reads);
// The alias names a live view: writes to the instance show through it.
const readable: Readable = session;
session.reads = 10;
console.log(readable.read(), session.reads);
console.log((readable as unknown as Session) === session);
// Omit over the same class, through a named alias.
function reset(view: Resettable): number {
view.reset();
return view.count();
}
console.log(reset(session));
// An alias of an alias resolves to the same view.
type AlsoReadable = Readable;
function once(view: AlsoReadable): string {
return view.read();
}
console.log(once(session), session.reads);
// Another class with the view's members flows through it the same way.
class Token {
reads = 0;
read(): string {
this.reads += 1;
return "t" + this.reads;
}
}
const token = new Token();
console.log(twice(token), token.reads);
@@ -0,0 +1,64 @@
// A Map or Set handed to a record type that spells one of its members stays
// the collection itself: later writes are visible through the view, and the
// view casts back to the same collection.
type Exists = { has(key: string): boolean };
function freeId(existing: Exists): string {
for (let i = 0; i < 5; i++) {
const id = "id" + i;
if (!existing.has(id)) return id;
}
return "none";
}
const byId = new Map<string, number>();
byId.set("id0", 1);
console.log(freeId(byId));
byId.set("id1", 2);
console.log(freeId(byId));
const seen = new Set<string>();
seen.add("id0");
seen.add("id1");
seen.add("id2");
console.log(freeId(seen));
// The view is live: entries added after the conversion are visible.
const view: Exists = byId;
byId.set("id3", 3);
console.log(view.has("id3"), byId.has("id3"));
console.log((view as unknown as Map<string, number>) === byId);
const setView: Exists = seen;
seen.add("id3");
console.log(setView.has("id3"), (setView as unknown as Set<string>) === seen);
// A record literal of the same shape keeps working.
console.log(freeId({ has: (key: string) => key === "id0" }));
// A member Map answers without a stored field.
type Sized = { readonly size: number };
const sized: Sized = byId;
byId.set("id4", 4);
console.log(sized.size, byId.size);
// A view handed on to a second structural type is still the collection: the
// second view's calls answer through it, and later writes stay visible.
type Lookup = { get(key: string): number | undefined };
type Table = { get(key: string): number | undefined; has(key: string): boolean };
function viaLookup(table: Lookup, key: string): number {
return table.get(key) ?? -1;
}
function viaTable(table: Table, key: string): number {
return table.has(key) ? viaLookup(table, key) : -2;
}
console.log(viaTable(byId, "id1"), viaTable(byId, "nope"));
byId.set("id5", 5);
console.log(viaTable(byId, "id5"));
type Member = { has(key: string): boolean };
function viaMember(member: Member, key: string): boolean {
return member.has(key);
}
function viaExists(existing: Exists, key: string): boolean {
return viaMember(existing, key);
}
console.log(viaExists(seen, "id3"), viaExists(seen, "id9"));
@@ -0,0 +1,42 @@
// An Error asserted to an interface that extends Error keeps the error
// itself: the extra fields live on the error object, and identity survives the
// conversion in both directions.
interface HttpError extends Error {
status: number | undefined;
body: string;
}
function httpError(label: string, status: number, body: string): HttpError {
const error = new Error(label + " failed") as HttpError;
error.status = status;
error.body = body;
return error;
}
const failure = httpError("GET", 500, "nope");
console.log(failure.message, failure.name, failure.status, failure.body);
console.log(failure instanceof Error);
console.log(typeof failure.stack);
function timeout(): HttpError {
const error = new Error("timed out") as HttpError;
error.name = "TimeoutError";
error.status = undefined;
error.body = "";
return error;
}
const late = timeout();
console.log(late.message, late.name, late.status, late.body === "");
// Passing the error through a function returns the same object.
function same(error: HttpError): HttpError {
return error;
}
const original = new Error("x") as HttpError;
original.status = 1;
original.body = "b";
console.log(same(original) === original, original.status, original.body);
// The error crosses out to unknown and back, still the same object.
const widened: unknown = failure;
console.log((widened as HttpError) === failure);
+74
View File
@@ -0,0 +1,74 @@
// Several promise arms in one union: `void | Promise<A> | Promise<void> |
// Decision` is one promise arm `Promise<A | undefined>` — `instanceof
// Promise` is true for either arm and `await` settles to the payload union.
interface Decision {
kind: "stop" | "continue";
note: string;
}
interface Settled {
reason: string;
}
type TurnResult = void | Promise<Settled> | Promise<void> | Decision;
function pick(n: number): TurnResult {
if (n === 0) return;
if (n === 1) return Promise.resolve({ reason: "one" });
if (n === 2) return Promise.resolve();
return { kind: "continue", note: "x" };
}
function fail(): void | Promise<Settled> {
return Promise.reject(new Error("boom"));
}
class Agent {
finishTurn?: (turn: number) => TurnResult;
async turn(n: number): Promise<string> {
const hook = this.finishTurn;
if (!hook) return "none";
const out = await hook(n);
if (out === undefined) return "void";
if ("note" in out) return `decision:${out.note}`;
return `settled:${out.reason}`;
}
}
async function main(): Promise<void> {
// A promise stored in the union is the same object: the payload
// conversion never re-boxes it.
const original = Promise.resolve({ reason: "one" });
const stored: TurnResult = original;
console.log("identity", stored === original);
const hit = pick(1);
console.log("promise", hit instanceof Promise, pick(3) instanceof Promise);
if (hit instanceof Promise) {
const settled = await hit;
console.log("narrowed", settled === undefined ? "void" : "object");
console.log("then", await hit.then((value) => (value === undefined ? "u" : "s")));
}
console.log("void", (await pick(0)) === undefined);
const settled = await pick(1);
console.log("awaited", typeof settled === "object" && "reason" in settled ? settled.reason : "?");
const decided = await pick(3);
console.log("decision", typeof decided === "object" && "note" in decided ? decided.note : "?");
const empty = pick(2);
if (empty instanceof Promise) await empty.then(() => console.log("then void"));
try {
await fail();
} catch (error) {
console.log("rejected", (error as Error).message);
}
const agent = new Agent();
console.log("no hook", await agent.turn(1));
agent.finishTurn = pick;
console.log("hook", await agent.turn(1), await agent.turn(3), await agent.turn(0));
}
void main();
+38
View File
@@ -0,0 +1,38 @@
// `Array.prototype.map` whose callback answers a CLOSURE: the result is an
// array of functions (`Array<() => Promise<T>>`, the runWithConcurrency
// task-list shape), each element one ScrClosure stored by reference.
interface Entry {
source: string;
scope: "user" | "project";
}
function tasksFor(entries: Entry[]): Array<() => Promise<string>> {
return entries.map((entry) => async () => `${entry.scope}:${entry.source}`);
}
function same(entries: Entry[]): Array<() => string> {
return entries.map((entry) => () => entry.source);
}
async function main(): Promise<void> {
const entries: Entry[] = [
{ source: "a", scope: "user" },
{ source: "b", scope: "project" },
];
const tasks = tasksFor(entries);
for (const task of tasks) console.log("task", await task());
const readers = same(entries);
// A function value is one closure for its whole life: identity through
// indexOf/includes holds.
const first = readers[0];
console.log("identity", readers.indexOf(first!), readers.includes(first!));
if (first) console.log("called", first());
const inline = entries.map((entry) => () => entry.source.length);
console.log("inline", inline.map((read) => read()).join(","));
const results = await Promise.all(tasks.map((task) => task()));
console.log("all", results.join(","));
}
void main();
@@ -0,0 +1,59 @@
// `for (const x of registry.get(key) ?? [])`: the receiver's type is the
// iterable beside `never[]` — the array literal's type, whose only value is
// the empty array, so that arm runs zero iterations.
function setLength(registry: Map<string, Set<string>>, key: string): number {
let total = 0;
for (const name of registry.get(key) ?? []) total += name.length;
return total;
}
function mapTotal(registry: Map<string, Map<string, number>>, key: string): number {
let total = 0;
for (const [name, value] of registry.get(key) ?? []) total += name.length + value;
return total;
}
function stringLength(registry: Map<string, string>, key: string): number {
let total = 0;
for (const ch of registry.get(key) ?? []) total += ch.length;
return total;
}
function main(): void {
const sets = new Map<string, Set<string>>();
sets.set("a", new Set(["xy", "z"]));
console.log("set", setLength(sets, "a"), setLength(sets, "missing"));
sets.get("a")?.add("q");
console.log("set live", setLength(sets, "a"));
const maps = new Map<string, Map<string, number>>();
maps.set("a", new Map([["xy", 3]]));
console.log("map", mapTotal(maps, "a"), mapTotal(maps, "missing"));
const strings = new Map<string, string>();
strings.set("a", "xyz");
console.log("string", stringLength(strings, "a"), stringLength(strings, "missing"));
// The same shape through an explicit union annotation.
const empty: Set<string> | never[] = sets.get("missing") ?? [];
let seen = 0;
for (const item of empty) seen += item.length;
console.log("annotated", seen);
// The head runs once: a lookup with a side effect is called one time, and
// the walk iterates the value the tag test saw.
let calls = 0;
const alternate = (): Set<string> | undefined => (++calls % 2 === 1 ? sets.get("a") : undefined);
let walked = 0;
for (const item of alternate() ?? []) walked += item.length;
console.log("once", calls, walked);
let mapCalls = 0;
const mapLookup = (): Map<string, number> | undefined => {
mapCalls++;
return maps.get("a");
};
let mapWalked = 0;
for (const [name, value] of mapLookup() ?? []) mapWalked += name.length + value;
console.log("map once", mapCalls, mapWalked);
}
main();
@@ -0,0 +1,71 @@
// A record whose field is a Promise converts into a checked-dynamic slot and
// back. The promise boxes by reference (SCR_DYN_PROMISE), so a round trip
// keeps the awaited payload and the promise's identity, exactly as in Node.
// A method answering the read state of a model store is the shape that raised
// SC1101: `reload?: { readers: number; promise: Promise<StoredModels> }`.
interface Reload {
readers: number;
promise: Promise<string>;
}
interface ReadState {
data: number;
revision?: string;
reload?: Reload;
}
interface Waiter {
label: string;
done: Promise<void>;
}
function summary(u: unknown): string {
const s = u as { data: number; revision?: string; reload?: { readers: number } };
return `${s.data}/${s.revision ?? "-"}/${s.reload ? s.reload.readers : "-"}`;
}
function reopen(u: unknown): ReadState {
return u as ReadState;
}
function labels(us: unknown[]): string {
return us
.map((u) => (u as Waiter).label)
.join(",");
}
async function main(): Promise<void> {
const payload = Promise.resolve("ready");
const state: ReadState = {
data: 7,
revision: "r1",
reload: { readers: 3, promise: payload },
};
console.log(summary(state));
// The checked conversion back keeps the promise itself.
const back: ReadState = reopen(state);
console.log(`same promise: ${back.reload?.promise === payload}`);
console.log(`payload: ${await back.reload?.promise}`);
// Reading the promise off the dynamic view awaits the same payload.
const live = (state as unknown) as { reload: { promise: unknown } };
console.log(`live payload: ${await live.reload.promise}`);
// An absent field stays absent, and a present-but-void promise settles.
const bare: ReadState = { data: 1 };
console.log(summary(bare), `no reload: ${bare.reload === undefined}`);
let settled = false;
const waiters: Waiter[] = [
{ label: "a", done: Promise.resolve() },
{ label: "b", done: Promise.resolve() },
];
waiters[0]!.done.then(() => {
settled = true;
});
console.log(`waiters: ${labels(waiters)}`);
await waiters[0]!.done;
console.log(`settled: ${settled}`);
}
main();
@@ -0,0 +1,50 @@
// A dynamic value crossing into a native record type that is recursive
// through a checked slot: a node's method answers the next node, so the
// checked domain re-enters the same record. The static check answers that
// back-edge coinductively and validates every reachable field, so the
// extraction compiles and the value behaves as in Node — including a Map
// field beside the recursion.
interface Entry {
label: string;
counts: Map<string, number>;
next(): Entry | undefined;
}
function build(label: string): Entry {
return {
label,
counts: new Map([["hits", label.length]]),
next() {
return label.length > 1 ? build(label.slice(1)) : undefined;
},
};
}
function coerce(value: any): Entry {
return value;
}
function walk(entry: Entry | undefined): string {
const parts: string[] = [];
let cursor = entry;
while (cursor !== undefined) {
parts.push(`${cursor.label}:${cursor.counts.get("hits")}`);
cursor = cursor.next();
}
return parts.join(">");
}
const root = build("abcd");
const dynamic: unknown = root;
const entry = coerce(dynamic);
console.log(entry.label, entry.counts.get("hits"));
console.log(walk(entry));
console.log(walk(entry.next()?.next()));
// The extracted value keeps the Map semantics: a mutation is visible through
// the extracted record and a new key inserts.
entry.counts.set("extra", 1);
entry.counts.set("hits", entry.counts.get("hits")! + 1);
console.log(entry.counts.size, entry.counts.get("hits"), entry.counts.has("extra"));
console.log([...entry.counts.keys()].join(","));
+5 -3
View File
@@ -10,11 +10,13 @@ function thenable(): Thenish | Promise<number> {
}
console.log(typeof thenable);
// Two promise arms: the union tag cannot tell them apart by instanceof.
function twoPromises(): Promise<number> | Promise<string> | boolean {
// Promise arms merge into one promise of their payload union (`await` settles
// to either payload). They do not merge when a payload has no union arm home:
// a RegExp payload is the promise slot's own, not an arm.
function splitPromises(): Promise<RegExp> | Promise<string> | boolean {
return true;
}
console.log(typeof twoPromises);
console.log(typeof splitPromises);
// Promises have no inspect lowering, so neither does a union holding one.
function maybe(b: boolean): string | Promise<string> {
+4 -1
View File
@@ -1,7 +1,8 @@
// A JavaScript package (the TypeScript namespace downlevel zod uses) whose
// namespace object assigns its own `toString` returning string | undefined;
// Number.parseInt with one argument; and a `then` whose handler result the
// declared union types (zod's ParseStatus.mergeArray pattern).
// declared union types (zod's ParseStatus.mergeArray pattern), over mixed and
// uniform promise arrays.
import { check, direct, mergeLater, note, show, showRadix, wholePart } from "nsutil";
console.log(String(direct("a")), String(direct({ message: "b" })), String(direct(undefined)));
@@ -12,5 +13,7 @@ console.log(values.map((value) => show(value)).join(" "), showRadix(5, 2), note.
async function main(): Promise<void> {
console.log(JSON.stringify(await mergeLater([Promise.resolve(1), Promise.resolve("two")], { ok: true })));
console.log(JSON.stringify(await mergeLater([Promise.resolve(3), Promise.resolve("four")], { ok: false })));
const uniform: Promise<number>[] = [Promise.resolve(5), Promise.resolve(6)];
console.log(JSON.stringify(await mergeLater(uniform, { ok: true })));
}
void main();
@@ -16,36 +16,36 @@ maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blo
hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it
maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise<string> | Promise<number>' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise<string>' and 'Promise<number>'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)
maybe-promise-fences.ts:16:10 - error SC2009: values of type 'boolean | Promise<string> | Promise<RegExp>' cannot be compiled: the union shape is supported, but its promise arms ('Promise<string>' and 'Promise<RegExp>') do not merge into one promise (their payloads have no common union layout)
13 | // Two promise arms: the union tag cannot tell them apart by instanceof.
14 | function twoPromises(): Promise<number> | Promise<string> | boolean {
| ^~~~~~~~~~~
15 | return true;
15 | // a RegExp payload is the promise slot's own, not an arm.
16 | function splitPromises(): Promise<RegExp> | Promise<string> | boolean {
| ^~~~~~~~~~~~~
17 | return true;
hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')
maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration
maybe-promise-fences.ts:19:20 - error SC2004: uses of 'splitPromises' inherit the blocker on its declaration
16 | }
17 | console.log(typeof twoPromises);
| ^~~~~~~~~~~
18 |
18 | }
19 | console.log(typeof splitPromises);
| ^~~~~~~~~~~~~
20 |
hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it
hint: the declaration of 'splitPromises' did not compile — fix the diagnostic reported there and these sites clear with it
maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
maybe-promise-fences.ts:25:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
22 | }
23 | console.log(maybe(true));
24 | }
25 | console.log(maybe(true));
| ^~~~~~~~~~~
24 |
26 |
maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise<string> | string' at this result type' is part of the standard library types but has no scriptc lowering yet
maybe-promise-fences.ts:29:50 - error SC2020: 'Promise.resolve over 'Promise<string> | string' at this result type' is part of the standard library types but has no scriptc lowering yet
26 | // must be that arm's own promise type.
27 | const widened = Promise.resolve<string | number>(maybe(false));
28 | // must be that arm's own promise type.
29 | const widened = Promise.resolve<string | number>(maybe(false));
| ^~~~~~~~~~~~
28 | console.log(typeof widened);
30 | console.log(typeof widened);
hint: the promise arm passes through identically, so it must already be the result's promise type
@@ -0,0 +1 @@
{ "plain": 2600, "san": 5500 }
@@ -0,0 +1 @@
{ "plain": 2600, "san": 5500 }
@@ -0,0 +1 @@
{ "plain": 2600, "san": 5500 }
@@ -0,0 +1 @@
{ "plain": 4400, "san": 5400 }
@@ -0,0 +1 @@
{ "plain": 3100, "san": 4600 }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 4100 }
@@ -0,0 +1 @@
{ "plain": 3400, "san": 21000 }
@@ -0,0 +1 @@
{ "plain": 2900, "san": 15300 }