Narrow unions of records by intersection refinements, and keep members an assertion attaches (#1484)

This commit is contained in:
Chris Tate
2026-10-20 01:50:19 -05:00
committed by GitHub
14 changed files with 742 additions and 12 deletions
@@ -0,0 +1,168 @@
/* Members a type assertion attaches to record values. */
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { type IrType, typeEquals } from "../../ir/ir.js";
import { hostsAll } from "./expressions/hosted-narrowing.js";
/** `route as Route & { order: number; internal: boolean }` followed by
* `view.internal = true` adds a property to the very object `route`
* names: Node has no layouts, so every later read of `route` (its JSON,
* its keys, a spread) sees the member. Native records are closed structs,
* and the asserted intersection maps to a different struct, so neither a
* conversion (a copy that severs the write from `route`) nor an erasure
* (a struct without the slot) is faithful.
*
* This pass finds such assertions before any body lowers and gives each
* record arm of the operand's type the asserted members it does not
* declare, as optional fields. Values the program builds hold them absent,
* which is invisible to every key surface (`in`, Object.keys, JSON,
* inspect, spread). The asserted type then refines the stored arms
* instead of naming new ones (hosted-narrowing.ts), so the assertion keeps
* the value and its writes land in the stored object.
*
* Only the plain form qualifies: one intersection whose constituents are
* the operand's own type and type literals of required property
* signatures, over plain data types (object types, aliases and interfaces;
* not classes or the standard library's types). Arms that are tuples or
* carry an index signature, members whose type does not map, and members
* two assertions type differently are left alone: those assertions keep
* their exact-shape diagnostics. Assertions whose operand already declares
* every member are skipped before any type maps, so programs without the
* form intern exactly the types they did before. */
export function extendAssertedRecordMembers(
lowerer: Lowerer,
files: readonly ts.SourceFile[],
): void {
const additions = new Map<string, Map<string, IrType>>();
const conflicts = new Set<string>();
const consider = (node: ts.AsExpression | ts.TypeAssertion): void => {
let typeNode: ts.TypeNode = node.type;
while (ts.isParenthesizedTypeNode(typeNode)) typeNode = typeNode.type;
if (!ts.isIntersectionTypeNode(typeNode)) return;
const parts = typeNode.types.nodes;
const literals = parts.filter((p) => ts.isTypeLiteralNode(p));
const bases = parts.filter((p) => !ts.isTypeLiteralNode(p));
if (literals.length === 0 || bases.length !== 1) return;
const memberNodes: { name: string; type: ts.TypeNode }[] = [];
for (const literal of literals) {
if (!ts.isTypeLiteralNode(literal)) return;
for (const member of literal.members.nodes) {
// A required member: the form that writes through the view. An
// optional one only reads, and keeps its exact-shape fence.
if (!ts.isPropertySignatureDeclaration(member) || !member.type || member.questionToken)
return;
const name = ts.isIdentifier(member.name)
? member.name.text
: ts.isStringLiteral(member.name)
? member.name.text
: null;
if (name === null || name.startsWith("%")) return;
memberNodes.push({ name, type: member.type });
}
}
// Checker-only gate first: an assertion every constituent of whose
// operand type already declares the members (`e as Expr & { kind:
// "mul" }`, the refinement idiom) attaches nothing, and mapping its
// types here would intern them out of the usual order.
const operandTs = lowerer.typeOf(node.expression);
const constituents = operandTs.isUnionType() ? ts.constituentTypes(operandTs) : [operandTs];
const declares = (t: ts.Type, name: string): boolean =>
lowerer.checker.getPropertyOfType(t, name) !== undefined;
if (constituents.every((t) => memberNodes.every((m) => declares(t, m.name)))) return;
// Plain data types only: object literal types, aliases and interfaces
// (not classes, and not the standard library's types).
const dataType = (t: ts.Type): boolean => {
const symbol = t.getAliasSymbol() ?? t.getSymbol();
return (
!!symbol &&
(symbol.flags & ts.SymbolFlags.Class) === 0 &&
symbol.declarations.length > 0 &&
symbol.declarations.every(
(d) =>
!lowerer.isStdlibFile(d.getSourceFile()) &&
(ts.isTypeLiteralNode(d) ||
ts.isInterfaceDeclaration(d) ||
ts.isTypeAliasDeclaration(d)),
)
);
};
if (!constituents.every(dataType)) return;
const operand = lowerer.mapTypeOf(operandTs);
const base = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(bases[0]!));
if (!operand || !base || !typeEquals(operand, base)) return;
const arms =
operand.kind === "union" ? (lowerer.unions.get(operand.unionId)?.arms ?? []) : [operand];
if (arms.length === 0) return;
const shapeIds: string[] = [];
for (const arm of arms) {
if (arm.kind !== "record") return;
const shape = lowerer.shapes.get(arm.shapeId);
if (!shape || shape.tuple || shape.indexValue) return;
shapeIds.push(arm.shapeId);
}
const members: { name: string; type: IrType }[] = [];
for (const { name, type } of memberNodes) {
if (shapeIds.every((id) => lowerer.shapes.get(id)!.fields.some((f) => f.name === name)))
continue;
const mapped = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(type));
if (!mapped || mapped.kind === "void") return;
const optional = lowerer.withUndefinedArmOf(mapped);
if (!optional) return;
members.push({ name, type: optional });
}
for (const shapeId of shapeIds) {
const shape = lowerer.shapes.get(shapeId)!;
for (const member of members) {
if (shape.fields.some((f) => f.name === member.name)) continue;
let added = additions.get(shapeId);
if (!added) additions.set(shapeId, (added = new Map()));
const prior = added.get(member.name);
if (prior && !typeEquals(prior, member.type)) conflicts.add(`${shapeId}:${member.name}`);
else added.set(member.name, member.type);
}
}
};
const visit = (node: ts.Node): void => {
if (ts.isAsExpression(node) || ts.isTypeAssertion(node)) consider(node);
ts.forEachChild(node, visit);
};
for (const sf of files) {
if (sf.isDeclarationFile) continue;
// Cheap text gate: the form needs an intersection with a type literal.
if (!/&\s*\{/.test(sf.text)) continue;
visit(sf);
}
for (const [shapeId, added] of additions) {
const extra = [...added]
.filter(([name]) => !conflicts.has(`${shapeId}:${name}`))
.map(([name, type]) => ({ name, type }));
lowerer.shapes.extendShape(shapeId, extra);
}
if (additions.size > 0) keepStoredTypeOfAssertedGlobals(lowerer, files);
}
/** A module-level `const view = value as T & { member: X }` binding keeps
* the stored type of `value` once the members are attached, exactly as a
* local binding does (lowerVarDecl): the assertion keeps the object, and
* writes through the binding must reach it. Globals took their checker
* (or annotated) type during collection, before the members existed; an
* annotation the stored type hosts gives way to it the same way. */
function keepStoredTypeOfAssertedGlobals(lowerer: Lowerer, files: readonly ts.SourceFile[]): void {
for (const sf of files) {
if (sf.isDeclarationFile) continue;
for (const statement of sf.statements.nodes) {
if (!ts.isVariableStatement(statement)) continue;
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
for (const decl of statement.declarationList.declarations.nodes) {
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (!ts.isAsExpression(init) && !ts.isTypeAssertion(init)) continue;
const global = lowerer.globalOf(decl.name);
const stored = lowerer.mapTypeOf(lowerer.typeOf(init.expression));
if (!global || !stored || (stored.kind !== "union" && stored.kind !== "record")) continue;
if (hostsAll(lowerer, stored, global.type)) global.type = stored;
}
}
}
}
@@ -0,0 +1,157 @@
/* Narrowings that refine record arms without naming a stored arm. */
import type { Lowerer } from "../lowerer.js";
import { type IrExpr, type IrType, type SrcLoc, typeEquals } from "../../../ir/ir.js";
/** A checker narrowing can refine the arms of a union of records with an
* intersection: a predicate `r is Route & { path: string }`, or the
* `"path" in r` narrowing, which intersects arms that do not declare the
* name with `Record<"path", unknown>`. Each narrowed arm then maps to a
* new record shape that is not an arm of the stored union, although the
* value never changed layout. A stored record arm HOSTS a narrowed record
* when both name the same members, each narrowed member refined to a
* subset of the stored slot type (or to `unknown`); a member an assertion
* attached to the stored shape (assertion-members.ts) may be missing from
* the narrowed one. A required member the stored arm does not declare
* cannot exist on its values: native records are closed, so `in` already
* answers false there and the narrowed arm is uninhabited.
*
* Only unions of two or more record arms without a literal discriminant
* qualify: discriminated unions narrow by their discriminant, and a
* record with unit arms keeps its own predicate-strengthening reads.
*
* Returns the stored arms that can hold a value of the narrowed type, or
* null when the union does not qualify, when some narrowed arm is neither
* a stored arm nor a record, or when no stored arm is excluded (there is
* nothing to narrow). */
export function hostedArmTags(
lowerer: Lowerer,
fromId: string,
narrowed: IrType,
): Set<number> | null {
const from = lowerer.unions.get(fromId);
if (!from || !recordUnion(lowerer, fromId)) return null;
const narrowedArms =
narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed];
if (!narrowedArms || narrowedArms.length === 0) return null;
const allowed = new Set<number>();
let refined = false;
for (const arm of narrowedArms) {
const direct = lowerer.armTag(fromId, arm);
if (direct >= 0) {
allowed.add(direct);
continue;
}
if (arm.kind !== "record") return null;
refined = true;
from.arms.forEach((stored, tag) => {
if (stored.kind === "record" && recordHosts(lowerer, stored.shapeId, arm.shapeId)) {
allowed.add(tag);
}
});
}
if (!refined || allowed.size === 0 || allowed.size === from.arms.length) return null;
return allowed;
}
/** A union of at least two record arms without a literal discriminant. */
function recordUnion(lowerer: Lowerer, unionId: string): boolean {
const def = lowerer.unions.get(unionId);
if (!def || def.discriminant) return false;
return def.arms.filter((arm) => arm.kind === "record").length >= 2;
}
/** Whether every value of `narrowed` (a checker refinement of a value
* stored as `storage`) lives in the stored layout: each narrowed arm is a
* stored arm or a record a stored record arm hosts (see hostedArmTags),
* and at least one needs the hosting. A union storage must qualify as in
* hostedArmTags; a single record only when an assertion attached members
* to it. A binding initialized by such an assertion keeps the stored type,
* so its member writes reach the asserted object. */
export function hostsAll(lowerer: Lowerer, storage: IrType, narrowed: IrType): boolean {
if (typeEquals(storage, narrowed)) return false;
let storedArms: readonly IrType[];
if (storage.kind === "union") {
if (!recordUnion(lowerer, storage.unionId)) return false;
storedArms = lowerer.unions.get(storage.unionId)!.arms;
} else if (storage.kind === "record" && lowerer.shapes.attachedNames(storage.shapeId).length) {
storedArms = [storage];
} else {
return false;
}
const narrowedArms =
narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed];
if (!narrowedArms || narrowedArms.length === 0) return false;
let refined = false;
for (const arm of narrowedArms) {
if (storedArms.some((stored) => typeEquals(stored, arm))) continue;
if (arm.kind !== "record") return false;
const hosted = storedArms.some(
(stored) => stored.kind === "record" && recordHosts(lowerer, stored.shapeId, arm.shapeId),
);
if (!hosted) return false;
refined = true;
}
return refined;
}
function recordHosts(lowerer: Lowerer, storedId: string, narrowedId: string): boolean {
const stored = lowerer.shapes.get(storedId);
const narrowed = lowerer.shapes.get(narrowedId);
if (!stored || !narrowed || stored.tuple || narrowed.tuple) return false;
if (stored.indexValue || narrowed.indexValue) return false;
const attached = lowerer.shapes.attachedNames(storedId);
for (const sf of stored.fields) {
if (!narrowed.fields.some((nf) => nf.name === sf.name) && !attached.includes(sf.name))
return false;
}
for (const nf of narrowed.fields) {
const sf = stored.fields.find((f) => f.name === nf.name);
if (!sf || !slotRefines(lowerer, nf.type, sf.type)) return false;
}
return true;
}
/** Whether a stored slot of type `stored` can be read as `narrowed`: the
* same type, `unknown`, or a subset of the stored union's arms (a read
* site narrows from the stored slot exactly as it does for a predicate
* strengthening one record). */
function slotRefines(lowerer: Lowerer, narrowed: IrType, stored: IrType): boolean {
if (typeEquals(narrowed, stored) || narrowed.kind === "dyn") return true;
if (stored.kind !== "union") return false;
if (narrowed.kind !== "union") return lowerer.armTag(stored.unionId, narrowed) >= 0;
const arms = lowerer.unions.get(narrowed.unionId)?.arms;
return !!arms && arms.every((arm) => lowerer.armTag(stored.unionId, arm) >= 0);
}
/** The stored view of a hosted narrowing (see hostedArmTags): one stored
* record arm extracted with the checked helper, or the sub-union of the
* hosting arms re-tagged with the excluded arms as traps. Both keep the
* value's stored layout, so member reads and writes reach the same
* object. Null when the narrowing is not a hosted one. */
export function hostedStorageView(
lowerer: Lowerer,
value: IrExpr,
narrowed: IrType,
loc: SrcLoc,
): IrExpr | null {
if (value.type.kind !== "union") return null;
const fromId = value.type.unionId;
const allowed = hostedArmTags(lowerer, fromId, narrowed);
if (!allowed) return null;
const from = lowerer.unions.get(fromId)!;
if (allowed.size === 1) {
const arm = from.arms[[...allowed][0]!]!;
const helper = lowerer.narrowedArmHelper(fromId, arm, loc);
return helper ? lowerer.checkedNarrowCall(helper, value, arm, loc) : null;
}
const arms = from.arms.filter((_, tag) => allowed.has(tag));
const subId = lowerer.unions.transform(from, arms);
const trappable = new Set<number>();
from.arms.forEach((_, tag) => {
if (!allowed.has(tag)) trappable.add(tag);
});
const helper = lowerer.unionRetagHelper(fromId, subId, loc, trappable);
if (!helper) return null;
const type: IrType = { kind: "union", unionId: subId };
return { kind: "call", callee: helper, args: [value], type, loc };
}
@@ -29,6 +29,7 @@ import { isOptionalProcessStreamProperty } from "../builtins/process.js";
import { literalValues } from "../../literal-values.js";
import { literalUnionArm } from "../../union-discriminants.js";
import { checkedArmExtraction } from "../coercions/unions.js";
import { hostedStorageView, hostsAll } from "./hosted-narrowing.js";
import { nodeThrowExpr, varRef } from "../../../ir/build.js";
import { InternalCompilerError } from "../../../errors.js";
import {
@@ -659,6 +660,12 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
};
}
}
// A narrowing that refines record arms with an intersection keeps the
// stored layout: view the value as the stored arms that can hold it.
if (narrowed?.kind === "union") {
const hosted = hostedStorageView(lowerer, expr, narrowed, expr.loc);
if (hosted) return hosted;
}
// A checker type narrowed to a UNIT arm (the `=== undefined` branch)
// also stays union-typed: a unit arm has no payload to extract, and
// nothing useful reads such a value anyway. (Standalone undefined maps
@@ -668,6 +675,14 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
return expr;
}
const tag = lowerer.armTag(expr.type.unionId, narrowed);
if (
tag < 0 &&
narrowed.kind === "record" &&
!lowerer.unions.get(expr.type.unionId)?.discriminant
) {
const hosted = hostedStorageView(lowerer, expr, narrowed, expr.loc);
if (hosted) return hosted;
}
if (tag < 0) {
// The checker may refine fields inside a discriminated record too.
// That creates a different mapped shape, but does not change the
@@ -926,6 +941,10 @@ export function runtimeOptionalReceiverRead(
loc,
);
if (!helper) {
// Intersection-refined record arms read through their stored
// layout (the predicate `r is Route & { path: string }`).
const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc);
if (hosted) return hosted;
lowerer.unsupported(
"SC1090",
expr,
@@ -946,6 +965,10 @@ export function runtimeOptionalReceiverRead(
// path below; mixed storage also needs to validate its concrete class.
if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view;
}
if (narrowed.kind === "record" && lowerer.armTag(local.type.unionId, narrowed) < 0) {
const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc);
if (hosted) return hosted;
}
const undefTag = lowerer.armTag(local.type.unionId, UNDEFINED_T);
const def = lowerer.unions.get(local.type.unionId);
if (!def || undefTag < 0)
@@ -1300,6 +1323,12 @@ export function lowerAsExpression(
) {
return lowerer.coerceInto(expr, inner, target);
}
// `job as Job & { startedAt: number }` on a record: the asserted
// record refines the stored one, so the assertion keeps the object
// (a width copy would sever writes through the asserted view).
if (inner.type.kind === "record" && target && hostsAll(lowerer, inner.type, target)) {
return inner;
}
// Static assertions normally erase, but record layouts are
// monomorphic: a consumer selected from the asserted shape must see
// that shape physically. Reuse the ordinary slot coercion so plain
@@ -1326,6 +1355,12 @@ export function lowerAsExpression(
return lowerer.checkedNarrowCall(helper, inner, target, locOf(expr));
}
}
// An intersection refining the stored record arms keeps the value
// (hosted-narrowing.ts): the stored arms that can hold it.
if (target && (target.kind === "union" || target.kind === "record")) {
const hosted = hostedStorageView(lowerer, inner, target, locOf(expr));
if (hosted) return hosted;
}
}
// `node.parent as Sub | undefined`: a union target whose class arm is
// narrower than the stored class gets the same checked class view as
@@ -38,7 +38,7 @@ import { lowerCryptoKeyInstanceof } from "../builtins/webcrypto.js";
import { lowerNetAddressInstanceof } from "../builtins/net-address.js";
import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js";
import { maybePromiseInstanceOf } from "../maybe-promise.js";
import { isSafeToDiscard } from "./evaluation-safety.js";
import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js";
import { type ClassInfo, storedClassValueType } from "../lower-classes.js";
import { classInstanceOf } from "../class-dynamic-dispatch.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -1429,13 +1429,14 @@ export function lowerInExpression(
// arm answers membership STATICALLY (a declared non-optional field is
// always present, an undeclared name never is), so the whole test
// collapses to runtime TAG tests — tsc's own narrowing then types the
// branches (the discriminating-`in` idiom). Optional (undefined-armed)
// fields and tuple/index-signature arms answer per VALUE, not per arm
// — those unions keep the fence below.
// branches (the discriminating-`in` idiom). An arm declaring the key
// as an optional (undefined-armed) slot answers per VALUE instead:
// its tag test joins that payload's presence test (see
// perValueUnionIn). Tuple/index-signature arms keep the fence below.
if (recv.type.kind === "union") {
const unionId = recv.type.unionId;
const arms = lowerer.unions.get(unionId)?.arms ?? [];
const answers: { tag: number; has: boolean }[] = [];
const answers: { tag: number; has: boolean | "slot"; arm: IrType }[] = [];
let staticAnswers = arms.length > 0;
for (const arm of arms) {
const tag = lowerer.armTag(unionId, arm);
@@ -1452,14 +1453,17 @@ export function lowerInExpression(
f.type.kind === "union" &&
lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0
) {
staticAnswers = false; // an optional slot: presence is per-value
break;
answers.push({ tag, has: "slot", arm }); // presence is per-value
continue;
}
// Accessor properties are own properties to `in` (Node answers
// true without invoking the getter) — either slot present makes
// the name a member.
const acc = shape.fields.some((x) => x.name === `%get:${key}` || x.name === `%set:${key}`);
answers.push({ tag, has: f !== undefined || acc || inherited });
answers.push({ tag, has: f !== undefined || acc || inherited, arm });
}
if (staticAnswers && answers.some((a) => a.has === "slot")) {
return perValueUnionIn(lowerer, recv, unionId, key, answers, loc);
}
if (staticAnswers) {
const pureRecv =
@@ -1611,6 +1615,75 @@ export function lowerInExpression(
);
}
/** `"k" in u` over a union of fixed record shapes where some arm declares
* `k` as an optional slot. Arms without the name answer false and arms
* with an always-present member answer true, both by tag; an optional
* slot answers its payload's own-property presence (an explicit
* undefined is present, an omitted or deleted property is not), read
* through the checked arm extraction the tag test has just proven. The
* receiver evaluates once: a non-repeatable one is stabilized in a
* hidden local first. */
function perValueUnionIn(
lowerer: Lowerer,
recv: IrExpr,
unionId: string,
key: string,
answers: readonly { tag: number; has: boolean | "slot"; arm: IrType }[],
loc: SrcLoc,
): IrExpr {
const recvType = recv.type;
const stable = isSafeToRepeat(recv) ? null : lowerer.declareHiddenLocal("%inRecv", recvType);
const ref = (): IrExpr => (stable ? varRef(stable.id, recvType, loc) : recv);
const isTag = (tag: number): IrExpr => ({
kind: "unionIsTag",
unionId,
tag,
negated: false,
value: ref(),
type: BOOL,
loc,
});
let out: IrExpr | null = null;
for (const a of answers) {
if (a.has === false) continue;
let test = isTag(a.tag);
if (a.has === "slot" && a.arm.kind === "record") {
test = {
kind: "logical",
op: "&&",
left: test,
right: {
kind: "recordHas",
obj: {
kind: "unionNarrow",
unionId,
tag: a.tag,
value: ref(),
type: a.arm,
loc,
},
shapeId: a.arm.shapeId,
field: key,
type: BOOL,
loc,
},
type: BOOL,
loc,
};
}
out = out ? { kind: "logical", op: "||", left: out, right: test, type: BOOL, loc } : test;
}
const result: IrExpr = out ?? { kind: "boolLit", value: false, type: BOOL, loc };
if (!stable) return result;
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: stable.id, init: recv, loc }],
result,
type: BOOL,
loc,
};
}
/** The runtime-key `in` (see lowerInExpression): `k in r` where k is a
* runtime string and r an index-signature record — an interned
* `%rec.haskey.<n>(k, r)` walks the declared names (a string-equality
@@ -224,12 +224,30 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
const loops: number[] = [];
let loopIds = 0;
const loopKey = (): string => loops.join("/");
/** Bindings initialized by another binding's reference (`const view =
* value`, the snapshot a union-receiver write takes), so writes through
* either count as writes through the one object. */
const aliases = new Map<string, { target: string; fn: string }>();
const bindingOf = (obj: IrExpr): string | null => {
let o = obj;
while (o.kind === "unionNarrow" || o.kind === "downcast") o = o.value;
if (o.kind !== "varRef") return null;
let id = o.localId;
const seen = new Set<string>();
for (;;) {
const alias = aliases.get(id);
if (!alias || alias.fn !== fnName || inits.get(id)?.count !== 1 || seen.has(id)) return id;
seen.add(id);
id = alias.target;
}
};
const noteInit = (id: string, init: IrExpr): void => {
const previous = inits.get(id);
if (previous !== undefined) {
previous.count++;
return;
}
if (init.kind === "varRef") aliases.set(id, { target: init.localId, fn: fnName });
const fresh =
init.kind === "recordLit" ||
init.kind === "recordClone" ||
@@ -315,7 +333,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
field: st.field,
kind: st.value.kind === "fieldAbsent" ? "delete" : "write",
fn: fnName,
binding: st.obj.kind === "varRef" ? st.obj.localId : null,
binding: bindingOf(st.obj),
loop: loopKey(),
pos,
});
@@ -1,3 +1,4 @@
import { hostsAll } from "./expressions/hosted-narrowing.js";
import {
matchAllRowsReadByElements,
storedMatchAllRowsReadByElements,
@@ -5481,6 +5482,23 @@ export function lowerVarDecl(
settledType = init.type;
}
}
// `const view = route as Route & { order: number }`: the asserted type
// refines the record arms the value is stored in (the assertion keeps
// that value, see hosted-narrowing.ts). The binding keeps the stored
// type, so writes through it reach the asserted object. An annotation
// the stored type hosts (`const view: Route & { order: number } = ...`)
// keeps it too: coercing into the annotated layout would copy the
// value and read members the writes have not created yet.
if (
!isLet &&
decl.initializer &&
isAssertionInitializer(decl.initializer) &&
(settledType.kind === "union" || settledType.kind === "record") &&
(init.type.kind === "union" || init.type.kind === "record") &&
hostsAll(lowerer, init.type, settledType)
) {
settledType = init.type;
}
// An exact class behind a generic interface keeps its native
// representation so generic calls can specialize against its body.
if (
@@ -11212,3 +11230,9 @@ export function stdlibBufferVarAlias(lowerer: Lowerer, decl: ts.VariableDeclarat
requireEarlyReference(lowerer.program, decl) === null
);
}
function isAssertionInitializer(node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
return ts.isAsExpression(e) || ts.isTypeAssertion(e);
}
@@ -1,3 +1,4 @@
import { extendAssertedRecordMembers } from "./assertion-members.js";
import { jsDynamicDeclarations } from "./js-dynamic-objects.js";
import {
lifetimeRegionType,
@@ -6781,6 +6782,10 @@ export class Lowerer {
}
this.collectProgram(parts);
this.speculativeTypeQueries(() => {
extendAssertedRecordMembers(
this,
parts.map((fp) => fp.sf),
);
this.analyzeRuntimeOptionalArrayReads(parts);
this.analyzeDateJsonResults(parts);
this.analyzeGuardedRecordReadGlobals(parts);
@@ -7188,6 +7193,10 @@ export class Lowerer {
};
this.collectProgram(parts);
this.speculativeTypeQueries(() => {
extendAssertedRecordMembers(
this,
parts.map((fp) => fp.sf),
);
this.analyzeRuntimeOptionalArrayReads(parts);
this.analyzeDateJsonResults(parts);
this.analyzeGuardedRecordReadGlobals(parts);
+50 -3
View File
@@ -325,7 +325,7 @@ export class ShapeRegistry {
) {
return;
}
shape.declaredOrder = declaredOrder;
shape.declaredOrder = this.withExtendedNames(shape.id, declaredOrder);
this.declaredOrderPriority.set(shape.id, this.currentDeclaredOrderPriority);
}
@@ -341,7 +341,7 @@ export class ShapeRegistry {
}
const shape = this.byId.get(shapeId);
if (!shape) continue;
shape.declaredOrder = best.order;
shape.declaredOrder = this.withExtendedNames(shapeId, best.order);
this.declaredOrderPriority.set(shapeId, best.priority);
}
for (const finalize of this.derivedDeclaredOrderFinalizers) finalize();
@@ -374,7 +374,7 @@ export class ShapeRegistry {
// still obeys first-writer-wins.
if (shape?.declaredOrder !== originalOrder) return;
const next = order();
if (next !== undefined) shape.declaredOrder = next;
if (next !== undefined) shape.declaredOrder = this.withExtendedNames(shape.id, next);
};
}
@@ -482,6 +482,53 @@ export class ShapeRegistry {
get(shapeId: string): IrRecordShape | undefined {
return this.byId.get(shapeId);
}
/** Names extendShape added to a shape, which every key-order table the
* shape adopts lists after its declared names. */
private readonly extendedNames = new Map<string, string[]>();
/** The members extendShape attached to a shape (empty for most). */
attachedNames(shapeId: string): readonly string[] {
return this.extendedNames.get(shapeId) ?? [];
}
private withExtendedNames(shapeId: string, order: string[]): string[] {
const extra = this.extendedNames.get(shapeId);
if (!extra) return order;
const missing = extra.filter((name) => !order.includes(name));
return missing.length === 0 ? order : [...order, ...missing];
}
/** Adds optional fields to an interned shape in place: members a type
* assertion attaches to the shape's values (`r as R & { tag: string }`
* followed by `view.tag = ...`; see assertion-members.ts). Every value
* of the shape holds them absent until a write through such a view, so
* the shape keeps its id, both structural keys resolve to it, and its
* key-order table lists the new names after the declared ones. Runs
* before any body lowers; existing layouts change, so the revision
* advances like a closing recursive placeholder. */
extendShape(shapeId: string, extra: readonly { name: string; type: IrType }[]): void {
const shape = this.byId.get(shapeId);
if (!shape || shape.tuple || extra.length === 0) return;
const fresh = extra.filter((f) => !shape.fields.some((x) => x.name === f.name));
if (fresh.length === 0) return;
shape.fields = [...shape.fields, ...fresh].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
this.extendedNames.set(shapeId, [
...(this.extendedNames.get(shapeId) ?? []),
...fresh.map((f) => f.name),
]);
if (shape.declaredOrder)
shape.declaredOrder = this.withExtendedNames(shapeId, shape.declaredOrder);
const candidates = this.declaredOrderCandidates.get(shapeId);
if (candidates) {
for (const c of candidates) c.order = this.withExtendedNames(shapeId, c.order);
}
this.revision++;
const key = this.keyOf(shape.fields, false, shape.indexValue);
if (!this.byKey.has(key)) this.byKey.set(key, shapeId);
}
}
/** The frontend's union interner — mirrors ShapeRegistry. A union's
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6460-in-union-optional-slots.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6461-predicate-intersection-narrowing.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6462-assertion-attached-members.ts"
],
"diags": []
}
@@ -0,0 +1,53 @@
// `"k" in u` over a union of records where some arms declare `k` as an
// optional slot: those arms answer by the value's own property presence
// (an explicit undefined is present, an omitted or deleted key is not),
// the other arms by their tag.
type Cond = { type: string; value?: string };
type HandleRoute = { handle: string; src?: string; dest?: string; status?: number };
type SourceRoute = {
src: string;
dest?: string;
headers?: { [key: string]: string };
middlewarePath?: string;
continue?: boolean;
status?: number;
has?: Cond[];
};
type Route = HandleRoute | SourceRoute;
const routes: Route[] = [
{ handle: "filesystem" },
{ src: "/a", middlewarePath: "/mw" },
{ src: "/b", dest: "/c", status: undefined },
{ handle: "miss", src: "/x", dest: undefined },
{ src: "/d", has: [{ type: "header" }] },
];
for (const r of routes) {
console.log("handle" in r, "middlewarePath" in r, "dest" in r, "status" in r, "has" in r, "toString" in r);
}
// A deleted optional key leaves the value; a later write brings it back.
const second = routes[1]!;
if ("middlewarePath" in second) {
delete second.middlewarePath;
}
console.log("middlewarePath" in second, JSON.stringify(second));
if (!("handle" in second)) {
second.middlewarePath = "/again";
}
console.log("middlewarePath" in second, JSON.stringify(second));
// The receiver evaluates once, even when it is a call.
let calls = 0;
function pick(i: number): Route {
calls++;
return routes[i]!;
}
console.log("dest" in pick(2), "dest" in pick(0), "src" in pick(3), calls);
// `in` as a narrowing test inside a predicate and a filter callback.
function hasMiddleware(route: Route): boolean {
return "middlewarePath" in route && typeof route.middlewarePath === "string";
}
console.log(routes.filter(hasMiddleware).length, routes.filter((r) => "status" in r).length);
@@ -0,0 +1,70 @@
// A predicate `r is Route & { path: string }` refines the record arms of
// a union without naming a stored arm: arms that do not declare `path`
// cannot hold such a value, the others are read through their own
// layout. The narrowed value can be read in place, in templates, in
// object literals and from closures.
type HandleRoute = { handle: string; src?: string; dest?: string };
type SourceRoute = { src: string; dest?: string; path?: string; status?: number };
type Route = HandleRoute | SourceRoute;
function isPathRoute(route: Route): route is Route & { path: string } {
return "path" in route && typeof route.path === "string";
}
type Output = { path: string; index: number };
function collect(result: { names: Set<string>; routes: Route[] }): Map<string, Output> {
const out = new Map<string, Output>();
for (const route of result.routes) {
if (!isPathRoute(route)) continue;
let name = route.path;
if (!result.names.has(name)) name = name.replace(/^\//, "");
if (!result.names.has(name)) {
throw new Error(`Mapping ${route.path} not found`);
}
out.set(name, { path: route.path, index: out.get(name)?.index ?? out.size });
}
return out;
}
const routes: Route[] = [
{ handle: "filesystem" },
{ src: "/a", path: "/one" },
{ src: "/b" },
{ src: "/c", path: "two" },
{ src: "/d", path: "/one", status: 308 },
];
console.log([...collect({ names: new Set(["one", "two"]), routes })]);
try {
collect({ names: new Set(), routes: [{ src: "/z", path: "/missing" }] });
} catch (e) {
console.log((e as Error).message);
}
// Closures capture the narrowed binding.
const readers: (() => string)[] = [];
for (const route of routes) {
if (!isPathRoute(route)) continue;
readers.push(() => `${route.src} -> ${route.path}`);
}
console.log(readers.map((read) => read()));
// Three arms, two of which can hold the refinement: the narrowed value is
// either of them, and their shared members still read.
type A = { kind: string; a: number; tag?: string };
type B = { kind: string; b: boolean; tag?: string };
type C = { kind: string; c: string };
type Item = A | B | C;
function isTagged(item: Item): item is Item & { tag: string } {
return "tag" in item && item.tag !== undefined;
}
const items: Item[] = [
{ kind: "a", a: 1, tag: "x" },
{ kind: "b", b: true },
{ kind: "c", c: "plain" },
{ kind: "b", b: false, tag: "y" },
];
for (const item of items) {
if (isTagged(item)) console.log(item.kind, item.tag.toUpperCase());
else console.log(item.kind, "untagged");
}
@@ -0,0 +1,58 @@
// `route as Route & { order: number; internal: boolean }` followed by
// writes through the asserted view adds the members to the object the
// original binding names: they appear wherever the route is observed
// later, in the order the writes created them.
type HandleRoute = { handle: string; src?: string; dest?: string; status?: number };
type SourceRoute = { src: string; dest?: string; path?: string; status?: number };
type Route = HandleRoute | SourceRoute;
function markInternal(input: Route[], out: Route[], order: number): void {
for (const route of input) {
const marked = route as Route & { internal: boolean; order: number };
marked.internal = true;
marked.order = order;
out.unshift(route);
}
}
const out: Route[] = [{ handle: "filesystem" }, { src: "/plain", dest: "/x" }];
markInternal([{ src: "/a" }, { handle: "miss" }, { src: "/b", status: 404 }], out, 3);
console.log(JSON.stringify(out));
console.log(out);
console.log(out.map((r) => ("internal" in (r as Route & { internal?: boolean }) ? "marked" : "plain")));
console.log(out.map((r) => ({ ...r })));
// Values the assertion never reached keep no trace of the members.
const fresh: Route = { src: "/new" };
console.log(JSON.stringify(fresh), Object.keys(fresh));
// A single record shape works the same way, and the asserted view reads
// back what it wrote.
type Job = { id: string; retries?: number };
const job: Job = { id: "j1" };
const tracked = job as Job & { startedAt: number };
tracked.startedAt = 42;
console.log(tracked.startedAt + 1, JSON.stringify(job), Object.keys(job));
// An annotated binding of the asserted type keeps the object as well:
// the members appear on it once written, not before.
function stamp(job: Job): Job & { finishedAt: number } {
const done: Job & { finishedAt: number } = job as Job & { finishedAt: number };
done.finishedAt = 7;
return done;
}
const second: Job = { id: "j2", retries: 1 };
console.log(stamp(second).finishedAt, JSON.stringify(second), Object.keys(second));
const third: Job = { id: "j3" };
const queued: Job & { queuedAt: number } = third as Job & { queuedAt: number };
queued.queuedAt = 9;
console.log(queued.queuedAt, JSON.stringify(third), Object.keys(third));
function markAnnotated(route: Route): void {
const marked: Route & { internal: boolean } = route as Route & { internal: boolean };
marked.internal = false;
}
const annotatedRoute: Route = { handle: "hit" };
markAnnotated(annotatedRoute);
console.log(JSON.stringify(annotatedRoute));