mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-02 07:43:42 +08:00
Pin constrained generic members and probe through assertions (#2443)
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/* A record assertion used as the object of a member PRESENCE PROBE:
|
||||
* `typeof (stored as R).member`, `(stored as R).member !== undefined`,
|
||||
* `null != (stored as R).member`.
|
||||
*
|
||||
* Node erases a type assertion, so the read answers off the value actually
|
||||
* stored: arms of the stored union that declare the member answer their
|
||||
* slot, and the others answer undefined (native records are closed). An
|
||||
* eager checked extraction of R at the assertion would instead trap on
|
||||
* every other arm, turning the probe — code that exists precisely BECAUSE
|
||||
* the stored value may be another arm — into a TypeError where Node
|
||||
* answers "undefined".
|
||||
*
|
||||
* The assertion's extraction is therefore deferred to the member read.
|
||||
* lowerAsExpression erases it for this use; property access answers the
|
||||
* read per arm when `assertedMemberRead` can (arms that declare the member
|
||||
* against arms that do not), and applies the extraction the assertion
|
||||
* would otherwise have taken eagerly when it cannot
|
||||
* (applyDeferredAssertion).
|
||||
*
|
||||
* Only a probe defers. A real use of the asserted value — the member
|
||||
* called, compared by identity, or read in any other context — keeps the
|
||||
* checked extraction, so a lying assertion still throws at the use
|
||||
* (divergence 38's lying-assertion stance). Types don't lie; a probe
|
||||
* simply isn't the use.
|
||||
*/
|
||||
import * as ts from "../../ts7/adapter.js";
|
||||
import { type IrExpr, type IrType, type SrcLoc, isUnitType, typeEquals } from "../../../ir/ir.js";
|
||||
import type { Lowerer } from "../lowerer.js";
|
||||
import { tryLowerExpression } from "./try-lower-expression.js";
|
||||
import { hostedStorageView } from "./hosted-narrowing.js";
|
||||
|
||||
/** The assertion node at the root of `node`, through parentheses; null
|
||||
* when `node` is not an (`as` / `<T>`) assertion. */
|
||||
export function assertedReceiverNode(
|
||||
node: ts.Expression,
|
||||
): ts.AsExpression | ts.TypeAssertion | null {
|
||||
let e = node;
|
||||
while (ts.isParenthesizedExpression(e)) e = e.expression;
|
||||
return ts.isAsExpression(e) || ts.isTypeAssertion(e) ? e : null;
|
||||
}
|
||||
|
||||
/** The property access this assertion is the receiver of, through
|
||||
* parentheses: `(u as R).member`. Null for any other use, and for the
|
||||
* optional chain `(u as R)?.member` (a unit arm answers undefined there
|
||||
* instead of Node's read-of-undefined TypeError). */
|
||||
function assertedReceiverAccess(
|
||||
assertion: ts.AsExpression | ts.TypeAssertion,
|
||||
): ts.PropertyAccessExpression | null {
|
||||
let use: ts.Node = assertion;
|
||||
for (;;) {
|
||||
const parent = use.parent;
|
||||
if (parent && ts.isParenthesizedExpression(parent) && parent.expression === use) {
|
||||
use = parent;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const access = use.parent;
|
||||
return access &&
|
||||
ts.isPropertyAccessExpression(access) &&
|
||||
access.expression === use &&
|
||||
access.questionDotToken === undefined
|
||||
? access
|
||||
: null;
|
||||
}
|
||||
|
||||
/** An operand that names the absent values (`undefined`, `null`), through
|
||||
* parentheses and the `void 0` spelling. */
|
||||
function isAbsenceLiteral(node: ts.Expression): boolean {
|
||||
let e = node;
|
||||
while (ts.isParenthesizedExpression(e)) e = e.expression;
|
||||
if (e.kind === ts.SyntaxKind.NullKeyword) return true;
|
||||
if (ts.isIdentifier(e)) return e.text === "undefined";
|
||||
return ts.isVoidExpression(e) && ts.isNumericLiteral(e.expression) && e.expression.text === "0";
|
||||
}
|
||||
|
||||
/** The property access this assertion is the receiver of when the read is
|
||||
* a presence probe: the operand of `typeof`, or an operand of `===` /
|
||||
* `!==` / `==` / `!=` against undefined or null. Null for the optional
|
||||
* chain and for every real use (a call's callee, an identity comparison,
|
||||
* a read that flows into a typed slot), which keep the assertion's checked
|
||||
* extraction. */
|
||||
export function assertedProbeAccessOf(
|
||||
assertion: ts.AsExpression | ts.TypeAssertion,
|
||||
): ts.PropertyAccessExpression | null {
|
||||
const access = assertedReceiverAccess(assertion);
|
||||
if (access === null) return null;
|
||||
let use: ts.Node = access;
|
||||
while (ts.isParenthesizedExpression(use.parent) && use.parent.expression === use)
|
||||
use = use.parent;
|
||||
const parent = use.parent;
|
||||
if (parent === undefined) return null;
|
||||
if (ts.isTypeOfExpression(parent) && parent.expression === use) return access;
|
||||
if (ts.isBinaryExpression(parent)) {
|
||||
const comparable = parent.left === use || (parent.right === use && parent.left !== use);
|
||||
if (!comparable) return null;
|
||||
const op = parent.operatorToken.kind;
|
||||
const equality =
|
||||
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
||||
op === ts.SyntaxKind.EqualsEqualsToken ||
|
||||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
|
||||
op === ts.SyntaxKind.ExclamationEqualsToken;
|
||||
if (!equality) return null;
|
||||
const other = parent.left === use ? parent.right : parent.left;
|
||||
if (isAbsenceLiteral(other)) return access;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when the union's record arms read `member` unevenly: at least one
|
||||
* declares it as a data field and at least one does not (it declares a
|
||||
* getter instead, or nothing). Those are exactly the arms
|
||||
* assertedMemberRead answers per arm, calling a getter where an arm has
|
||||
* one; anything else keeps the assertion's extraction (and with it the
|
||||
* checked-narrow fence for a lying one), as does an arm with only a setter
|
||||
* of that name. */
|
||||
export function partialRecordMember(lowerer: Lowerer, union: IrType, member: string): boolean {
|
||||
if (union.kind !== "union") return false;
|
||||
const arms = lowerer.unions.get(union.unionId)?.arms ?? [];
|
||||
let records = 0;
|
||||
let declaring = 0;
|
||||
for (const arm of arms) {
|
||||
if (isUnitType(arm)) continue;
|
||||
if (arm.kind !== "record") return false;
|
||||
const shape = lowerer.shapes.get(arm.shapeId);
|
||||
if (!shape || shape.tuple || shape.indexValue) return false;
|
||||
const getter = shape.fields.some((field) => field.name === `%get:${member}`);
|
||||
if (!getter && shape.fields.some((field) => field.name === `%set:${member}`)) return false;
|
||||
records++;
|
||||
if (shape.fields.some((field) => field.name === member)) declaring++;
|
||||
}
|
||||
return records > 0 && declaring > 0 && declaring < records;
|
||||
}
|
||||
|
||||
/** The asserted record type whose checked extraction is deferred for the
|
||||
* probe `access`, or null when the read keeps the ordinary assertion
|
||||
* lowering. `value` is the assertion's lowered operand. */
|
||||
export function deferredMemberReadTarget(
|
||||
lowerer: Lowerer,
|
||||
assertion: ts.AsExpression | ts.TypeAssertion,
|
||||
access: ts.PropertyAccessExpression,
|
||||
value: IrExpr,
|
||||
): IrType | null {
|
||||
if (assertedProbeAccessOf(assertion) !== access) return null;
|
||||
if (value.type.kind !== "union") return null;
|
||||
const target = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(assertion.type));
|
||||
if (target?.kind !== "record") return null;
|
||||
if (!partialRecordMember(lowerer, value.type, access.name.text)) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Whether the property access `expr` is a probe read through an assertion
|
||||
* that the per-arm answer claims (the same shape deferredMemberReadTarget
|
||||
* recognizes). Checker-derived field targets must not claim it first: the
|
||||
* field target would apply the assertion's checked extraction and trap on
|
||||
* the arm the probe exists to detect. */
|
||||
export function deferredProbeReadAccess(
|
||||
lowerer: Lowerer,
|
||||
access: ts.PropertyAccessExpression,
|
||||
): boolean {
|
||||
const assertion = assertedReceiverNode(access.expression);
|
||||
if (assertion === null) return false;
|
||||
if (assertedProbeAccessOf(assertion) !== access) return false;
|
||||
const value = tryLowerExpression(lowerer, access.expression);
|
||||
return value !== null && deferredMemberReadTarget(lowerer, assertion, access, value) !== null;
|
||||
}
|
||||
|
||||
/** The stored value of an `in` probe whose receiver is an erased assertion
|
||||
* (`"stream" in (stored as R)`): Node asks membership of the value actually
|
||||
* stored, which for a union of fixed record shapes the union path answers
|
||||
* per arm. Null when the receiver is not such an assertion, or the stored
|
||||
* value is not a union of fixed record shapes (those keep the ordinary
|
||||
* lowering, and its refusals). */
|
||||
export function assertedInOperand(lowerer: Lowerer, node: ts.Expression): IrExpr | null {
|
||||
let rhs = node;
|
||||
while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression;
|
||||
if (!ts.isAsExpression(rhs) && !ts.isTypeAssertion(rhs)) return null;
|
||||
const stored = tryLowerExpression(lowerer, rhs.expression);
|
||||
if (stored === null || stored.type.kind !== "union") return null;
|
||||
const arms = lowerer.unions.get(stored.type.unionId)?.arms ?? [];
|
||||
const fixed = arms.every((arm) => {
|
||||
if (arm.kind !== "record") return false;
|
||||
const shape = lowerer.shapes.get(arm.shapeId);
|
||||
return shape !== undefined && !shape.tuple && !shape.indexValue;
|
||||
});
|
||||
return arms.length > 0 && fixed ? stored : null;
|
||||
}
|
||||
|
||||
/** Apply the extraction lowerAsExpression would have applied to the
|
||||
* assertion's operand, for a member read with no per-arm answer. */
|
||||
export function applyDeferredAssertion(
|
||||
lowerer: Lowerer,
|
||||
value: IrExpr,
|
||||
target: IrType,
|
||||
loc: SrcLoc,
|
||||
): IrExpr {
|
||||
if (value.type.kind === "union" && target.kind !== "union" && !typeEquals(target, value.type)) {
|
||||
const helper = lowerer.narrowedArmHelper(value.type.unionId, target, loc);
|
||||
if (helper !== null) return lowerer.checkedNarrowCall(helper, value, target, loc);
|
||||
}
|
||||
if (target.kind === "union" || target.kind === "record") {
|
||||
const hosted = hostedStorageView(lowerer, value, target, loc);
|
||||
if (hosted) return hosted;
|
||||
}
|
||||
lowerer.assertedValues.set(value, target);
|
||||
return value;
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { assertsGlobalObject, extractionCopiesReference } from "../assertion-ide
|
||||
import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js";
|
||||
import { isDeclBoundaryTypeNode } from "../../decl-projection.js";
|
||||
import { isMessageListenerCall } from "./message-listeners.js";
|
||||
import { assertedProbeAccessOf, deferredMemberReadTarget } from "./asserted-receiver.js";
|
||||
|
||||
/** `c ? a : b`. An explicit destination constructs each branch at that
|
||||
* layout before joining it. Without one, retain the expression's inferred
|
||||
@@ -1407,6 +1408,17 @@ export function lowerAsExpression(
|
||||
if (inner.type.kind !== "dyn") {
|
||||
const targetTs0 = lowerer.checker.getTypeFromTypeNode(expr.type);
|
||||
const target = lowerer.mapTypeOf(targetTs0);
|
||||
// A PRESENCE PROBE of a member read through the assertion (`typeof
|
||||
// (u as R).member`, `(u as R).member !== undefined`): the read answers
|
||||
// off the value actually stored, since Node erases the assertion and an
|
||||
// arm that does not declare the member answers undefined. The read
|
||||
// answers that per arm (assertedMemberRead); an eager extraction of R
|
||||
// here would trap first and turn the probe into a TypeError. Defer to
|
||||
// the read (asserted-receiver.ts); every real use keeps the extraction.
|
||||
{
|
||||
const access = assertedProbeAccessOf(expr);
|
||||
if (access && deferredMemberReadTarget(lowerer, expr, access, inner) !== null) return inner;
|
||||
}
|
||||
if (target?.kind === "object") {
|
||||
const asserted = checkedClassAssertion(lowerer, inner, target, locOf(expr));
|
||||
if (asserted) return asserted;
|
||||
|
||||
@@ -125,6 +125,12 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../.
|
||||
import { typedArrayLacksMember } from "../builtins/typed-array-members.js";
|
||||
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
|
||||
import { tryLowerExpression } from "./try-lower-expression.js";
|
||||
import {
|
||||
applyDeferredAssertion,
|
||||
assertedReceiverNode,
|
||||
deferredMemberReadTarget,
|
||||
deferredProbeReadAccess,
|
||||
} from "./asserted-receiver.js";
|
||||
import { lowerRecordInheritedMember } from "../record-inherited.js";
|
||||
import { DATE_METHOD_HINT } from "../builtins/dates.js";
|
||||
import {
|
||||
@@ -1398,6 +1404,25 @@ export function lowerPropertyAccessExpr(
|
||||
// receiver that lowers cleanly means the MEMBER is the gap — name
|
||||
// the property and the receiver's type instead of "property access".
|
||||
let recvLowered = lowerer.lowerExpr(expr.expression);
|
||||
// A PRESENCE PROBE of a member read through an erased assertion
|
||||
// (`typeof (u as R).member`, `(u as R).member !== undefined`): Node
|
||||
// reads the property off the value actually stored, so an arm that does
|
||||
// not declare it answers undefined. lowerAsExpression deferred the
|
||||
// assertion's checked extraction for this read; answer it per arm
|
||||
// (assertedMemberRead), and only a member with no per-arm answer applies
|
||||
// the extraction the assertion would otherwise have taken (a lying
|
||||
// assertion still throws on a real use, never on the probe).
|
||||
{
|
||||
const assertion = assertedReceiverNode(expr.expression);
|
||||
const target = assertion
|
||||
? deferredMemberReadTarget(lowerer, assertion, expr, recvLowered)
|
||||
: null;
|
||||
if (target !== null) {
|
||||
const read = assertedMemberRead(lowerer, expr, recvLowered, loc);
|
||||
if (read) return read;
|
||||
recvLowered = applyDeferredAssertion(lowerer, recvLowered, target, loc);
|
||||
}
|
||||
}
|
||||
// A receiver whose value union holds record types the checker's subtype
|
||||
// reduction folded into its type (`{ status } | { status, value }` typed
|
||||
// `{ status }`, a call of a function with an inferred return type): read
|
||||
@@ -1692,7 +1717,9 @@ function assertedMemberRead(
|
||||
const name = expr.name.text;
|
||||
let resultType: IrType | null = null;
|
||||
let records = 0;
|
||||
const declaring: { tag: number; shapeId: string; type: IrType }[] = [];
|
||||
// A getter arm answers by calling its getter, once per read, as Node's
|
||||
// read does; a data arm reads its slot.
|
||||
const declaring: { tag: number; shapeId: string; type: IrType; getter: boolean }[] = [];
|
||||
const units: number[] = [];
|
||||
for (let tag = 0; tag < arms.length; tag++) {
|
||||
const arm = arms[tag]!;
|
||||
@@ -1704,16 +1731,21 @@ function assertedMemberRead(
|
||||
records++;
|
||||
const shape = lowerer.shapes.get(arm.shapeId);
|
||||
if (!shape || shape.tuple || shape.indexValue) return null;
|
||||
const field = shape.fields.find((f) => f.name === name);
|
||||
const getter = shape.fields.find((f) => f.name === `%get:${name}`);
|
||||
if (!getter && shape.fields.some((f) => f.name === `%set:${name}`)) return null;
|
||||
if (getter && getter.type.kind !== "func") return null;
|
||||
const field = getter ?? shape.fields.find((f) => f.name === name);
|
||||
if (!field) continue;
|
||||
const fieldType = field.type.kind === "func" && getter ? field.type.ret : field.type;
|
||||
// The declaring arms agree up to an undefined arm (`string` beside
|
||||
// `string | undefined`).
|
||||
const armResult = lowerer.withUndefinedArmOf(field.type);
|
||||
const armResult = lowerer.withUndefinedArmOf(fieldType);
|
||||
if (!armResult || (resultType && !typeEquals(resultType, armResult))) return null;
|
||||
resultType = armResult;
|
||||
declaring.push({ tag, shapeId: arm.shapeId, type: field.type });
|
||||
declaring.push({ tag, shapeId: arm.shapeId, type: field.type, getter: getter !== undefined });
|
||||
}
|
||||
if (!resultType || declaring.length === records) return null;
|
||||
if (!resultType || (declaring.length === records && declaring.every((d) => !d.getter)))
|
||||
return null;
|
||||
const stable = lowerer.declareHiddenLocal("%assertedRecv", recv.type);
|
||||
const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: recv.type, loc });
|
||||
let result: IrExpr | null = lowerer.wrappedUndefined(resultType, loc);
|
||||
@@ -1736,15 +1768,19 @@ function assertedMemberRead(
|
||||
loc,
|
||||
};
|
||||
}
|
||||
for (const { tag, shapeId, type: fieldType } of declaring) {
|
||||
const read: IrExpr = {
|
||||
for (const { tag, shapeId, type: fieldType, getter } of declaring) {
|
||||
const slot: IrExpr = {
|
||||
kind: "recordGet",
|
||||
obj: { kind: "unionNarrow", unionId, tag, value: ref(), type: arms[tag]!, loc },
|
||||
shapeId,
|
||||
field: name,
|
||||
field: getter ? `%get:${name}` : name,
|
||||
type: fieldType,
|
||||
loc,
|
||||
};
|
||||
const read: IrExpr =
|
||||
getter && fieldType.kind === "func"
|
||||
? { kind: "callValue", callee: slot, args: [], type: fieldType.ret, loc }
|
||||
: slot;
|
||||
result = {
|
||||
kind: "ternary",
|
||||
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: ref(), type: BOOL, loc },
|
||||
@@ -3145,6 +3181,12 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi
|
||||
};
|
||||
}
|
||||
}
|
||||
// A presence probe of a member read through a record assertion
|
||||
// (`typeof (u as R).member`): the field target comes from the ASSERTED
|
||||
// checker type and would apply the checked extraction, trapping on the
|
||||
// very arm the probe exists to detect. Leave the read to the per-arm
|
||||
// answer in lowerPropertyAccessExpr (asserted-receiver.ts).
|
||||
if (deferredProbeReadAccess(lowerer, expr)) return null;
|
||||
const target = lowerer.fieldTarget(expr);
|
||||
if (target) return lowerer.fieldGetExpr(target, locOf(expr), expr);
|
||||
if (expr.questionDotToken) return null;
|
||||
|
||||
@@ -50,6 +50,7 @@ import { type ClassInfo, storedClassValueType } from "../lower-classes.js";
|
||||
import { classInstanceOf } from "../class-dynamic-dispatch.js";
|
||||
import { InternalCompilerError } from "../../../errors.js";
|
||||
import { tryLowerExpression } from "./try-lower-expression.js";
|
||||
import { assertedInOperand } from "./asserted-receiver.js";
|
||||
import { isJsSourceFile, locOf } from "../../program.js";
|
||||
import { checkedClassInstanceOf } from "../class-construction.js";
|
||||
import { unsupportedDiag } from "../../../diagnostics/diagnostic.js";
|
||||
@@ -1672,7 +1673,11 @@ export function lowerInExpression(
|
||||
if (probed) return probed;
|
||||
}
|
||||
}
|
||||
let recv = lowerer.lowerExpr(expr.right);
|
||||
// `"stream" in (stored as R)` probes the value actually stored (Node
|
||||
// erases the assertion): a union of fixed record shapes answers per arm
|
||||
// below, so the assertion's checked extraction must not run first (it
|
||||
// would trap on the very arm the probe exists to detect).
|
||||
let recv = assertedInOperand(lowerer, expr.right) ?? lowerer.lowerExpr(expr.right);
|
||||
const classInfo =
|
||||
recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined;
|
||||
if (
|
||||
@@ -1877,17 +1882,17 @@ export function lowerInExpression(
|
||||
return perValueUnionIn(lowerer, recv, unionId, key, answers, loc);
|
||||
}
|
||||
if (staticAnswers) {
|
||||
const pureRecv =
|
||||
recv.kind === "varRef" ||
|
||||
recv.kind === "recordGet" ||
|
||||
recv.kind === "fieldGet" ||
|
||||
pureRecvNode;
|
||||
// A field read is pure only over a pure object: the stored value of
|
||||
// an asserted receiver (`"k" in (make().stored as R)`) still
|
||||
// evaluates its call.
|
||||
const pureRecv = isSafeToRepeat(recv) || pureRecvNode;
|
||||
let value = recv;
|
||||
const isTag = (tag: number, negated: boolean): IrExpr => ({
|
||||
kind: "unionIsTag",
|
||||
unionId,
|
||||
tag,
|
||||
negated,
|
||||
value: recv,
|
||||
value,
|
||||
type: BOOL,
|
||||
loc,
|
||||
});
|
||||
@@ -1913,27 +1918,29 @@ export function lowerInExpression(
|
||||
if (trues.length === 1) return isTag(trues[0]!.tag, false);
|
||||
const falses = answers.filter((a) => !a.has);
|
||||
if (falses.length === 1) return isTag(falses[0]!.tag, true);
|
||||
// Several arms on each side: the OR chain re-reads the receiver,
|
||||
// so only side-effect-free reads qualify.
|
||||
if (pureRecv) {
|
||||
let out: IrExpr = isTag(trues[0]!.tag, false);
|
||||
for (const t of trues.slice(1)) {
|
||||
out = {
|
||||
kind: "logical",
|
||||
op: "||",
|
||||
left: out,
|
||||
right: isTag(t.tag, false),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
// Several arms on each side: the OR chain re-reads the receiver, so
|
||||
// anything but a pure read is evaluated once into a hidden local.
|
||||
const stable = pureRecv ? null : lowerer.declareHiddenLocal("%inRecv", recv.type);
|
||||
if (stable) value = varRef(stable.id, recv.type, loc);
|
||||
let out: IrExpr = isTag(trues[0]!.tag, false);
|
||||
for (const t of trues.slice(1)) {
|
||||
out = {
|
||||
kind: "logical",
|
||||
op: "||",
|
||||
left: out,
|
||||
right: isTag(t.tag, false),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
expr,
|
||||
"statically-decided 'in' on computed receivers (bind the value to a variable first)",
|
||||
);
|
||||
if (!stable) return out;
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts: [{ kind: "varDecl", localId: stable.id, init: recv, loc }],
|
||||
result: out,
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
}
|
||||
// A literal member name over a typed array (`"BYTES_PER_ELEMENT" in a`):
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// A feature-detection probe of a member read through a type assertion must
|
||||
// answer off the value actually stored (Node erases `as`), never throw the
|
||||
// checked-extraction TypeError. A real use of the same read keeps the
|
||||
// checked conversion ("types don't lie": a lying assertion throws at the
|
||||
// use, not at the probe).
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule, validateModule } from "../src/index.js";
|
||||
|
||||
const PRELUDE = [
|
||||
"interface Streams { stream: () => string; fetch?: () => string }",
|
||||
"interface Options<K extends string> { name: string; api: Streams | Record<K, Streams> }",
|
||||
];
|
||||
|
||||
type IrModule = {
|
||||
entry: string;
|
||||
functions: { name: string; body: unknown }[];
|
||||
};
|
||||
|
||||
async function compileToIr(
|
||||
body: string[],
|
||||
): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-asserted-arm-probe-"));
|
||||
try {
|
||||
const entry = join(dir, "main.ts");
|
||||
const outPath = join(dir, "main.ir.json");
|
||||
await writeFile(entry, [...PRELUDE, ...body].join("\n"));
|
||||
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
return {
|
||||
ok: false,
|
||||
module: null,
|
||||
diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
|
||||
};
|
||||
const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule;
|
||||
expect(validateModule(module as never)).toEqual([]);
|
||||
return { ok: true, module, diagnostics: "" };
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The IR bodies of the functions whose name starts with `root`, as text
|
||||
* (a specialized function keeps its source name as a prefix). */
|
||||
function bodies(module: IrModule, root: string): string {
|
||||
return JSON.stringify(
|
||||
module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body),
|
||||
);
|
||||
}
|
||||
|
||||
test("typeof probe of a member read through an assertion reads per arm", async () => {
|
||||
const compiled = await compileToIr([
|
||||
"function detect<K extends string>(input: Options<K>): boolean {",
|
||||
' return typeof (input.api as Streams).stream === "function";',
|
||||
"}",
|
||||
'console.log(detect({ name: "one", api: { stream: () => "one" } }));',
|
||||
]);
|
||||
expect(compiled.ok, compiled.diagnostics).toBe(true);
|
||||
// A checked extraction of the asserted arm would trap on the record arm;
|
||||
// the probe answers from the stored union's runtime tag instead.
|
||||
const detect = bodies(compiled.module!, "detect");
|
||||
expect(detect).toContain('"unionIsTag"');
|
||||
expect(detect).not.toContain('"nodeThrow"');
|
||||
});
|
||||
|
||||
test("undefined and null presence probes read per arm", async () => {
|
||||
for (const probe of [
|
||||
"(input.api as Streams).stream !== undefined",
|
||||
"(input.api as Streams).stream == null",
|
||||
]) {
|
||||
const compiled = await compileToIr([
|
||||
"function detect<K extends string>(input: Options<K>): boolean {",
|
||||
` return ${probe};`,
|
||||
"}",
|
||||
'console.log(detect({ name: "one", api: { stream: () => "one" } }));',
|
||||
]);
|
||||
expect(compiled.ok, compiled.diagnostics).toBe(true);
|
||||
const detect = bodies(compiled.module!, "detect");
|
||||
expect(detect).not.toContain('"nodeThrow"');
|
||||
}
|
||||
});
|
||||
|
||||
test("a real use of the asserted member keeps the checked extraction", async () => {
|
||||
const compiled = await compileToIr([
|
||||
"function call<K extends string>(input: Options<K>): string {",
|
||||
" return (input.api as Streams).stream();",
|
||||
"}",
|
||||
'console.log(call({ name: "one", api: { stream: () => "one" } }));',
|
||||
]);
|
||||
expect(compiled.ok, compiled.diagnostics).toBe(true);
|
||||
// The real use still applies the assertion's checked extraction: an arm
|
||||
// that is not Streams throws at the read (the trap is the nodeThrow).
|
||||
expect(bodies(compiled.module!, "call")).toMatch(/union\.narrow|nodeThrow/);
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/asserted-arm-probe.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-member-constrained-lambda-impls.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Feature-detection probes of a member read through a type assertion.
|
||||
//
|
||||
// `input.api` is `Streams | Partial<Record<TApi, Streams>>` (optional) or
|
||||
// `Streams | Record<K, Streams>` (required). Node erases `as`, so
|
||||
// `(input.api as Streams).stream` reads the property off the value actually
|
||||
// stored: when that is the record arm, the read answers undefined. The
|
||||
// probe therefore answers "undefined" (false), where an eager checked
|
||||
// extraction of Streams would throw on the record arm. A real use of the
|
||||
// same read — calling the member when the wrong arm is stored — still
|
||||
// throws, at the call.
|
||||
type Api = "alpha" | "beta";
|
||||
|
||||
interface Streams {
|
||||
stream: () => string;
|
||||
fetch?: () => string;
|
||||
}
|
||||
|
||||
interface Options<TApi extends Api> {
|
||||
name: string;
|
||||
api?: Streams | Partial<Record<TApi, Streams>>;
|
||||
}
|
||||
|
||||
interface Required<K extends string> {
|
||||
name: string;
|
||||
api: Streams | Record<K, Streams>;
|
||||
}
|
||||
|
||||
function detectTypeof<TApi extends Api>(input: Options<TApi>): boolean {
|
||||
return typeof (input.api as Streams).stream === "function";
|
||||
}
|
||||
|
||||
function detectUndefined<TApi extends Api>(input: Options<TApi>): boolean {
|
||||
return (input.api as Streams).stream !== undefined;
|
||||
}
|
||||
|
||||
function detectNullish<TApi extends Api>(input: Options<TApi>): boolean {
|
||||
return (input.api as Streams).stream == null;
|
||||
}
|
||||
|
||||
function detectRequired<K extends string>(input: Required<K>): boolean {
|
||||
return typeof (input.api as Streams).stream === "function";
|
||||
}
|
||||
|
||||
function requiredIn<K extends string>(input: Required<K>): boolean {
|
||||
return "stream" in (input.api as Streams);
|
||||
}
|
||||
|
||||
// The probe guards the real use: the call runs only on the arm that has
|
||||
// the member, so the assertion never lies at the call.
|
||||
function streamName<TApi extends Api>(input: Options<TApi>): string {
|
||||
if (typeof (input.api as Streams).stream === "function") return (input.api as Streams).stream();
|
||||
return "none";
|
||||
}
|
||||
|
||||
// A real use that reaches the wrong arm throws, as it does in Node.
|
||||
function callStream<TApi extends Api>(input: Options<TApi>): string {
|
||||
try {
|
||||
return (input.api as Streams).stream();
|
||||
} catch (err) {
|
||||
return err instanceof TypeError ? "TypeError" : "other";
|
||||
}
|
||||
}
|
||||
|
||||
const one: Streams = { stream: () => "one" };
|
||||
const withFetch: Streams = { stream: () => "both", fetch: () => "fetch" };
|
||||
const registry: Partial<Record<Api, Streams>> = { alpha: one, beta: withFetch };
|
||||
const empty: Partial<Record<Api, Streams>> = {};
|
||||
const required: Record<Api, Streams> = { alpha: one, beta: withFetch };
|
||||
|
||||
console.log(detectTypeof({ name: "one", api: one }));
|
||||
console.log(detectTypeof({ name: "registry", api: registry }));
|
||||
console.log(detectTypeof({ name: "empty", api: empty }));
|
||||
console.log(detectUndefined({ name: "one", api: one }));
|
||||
console.log(detectUndefined({ name: "registry", api: registry }));
|
||||
console.log(detectNullish({ name: "one", api: one }));
|
||||
console.log(detectNullish({ name: "registry", api: empty }));
|
||||
console.log(streamName({ name: "one", api: one }));
|
||||
console.log(streamName({ name: "registry", api: registry }));
|
||||
console.log(detectRequired({ name: "one", api: one }));
|
||||
console.log(detectRequired<"alpha" | "beta">({ name: "registry", api: required }));
|
||||
console.log(requiredIn({ name: "one", api: one }));
|
||||
console.log(requiredIn<"alpha" | "beta">({ name: "registry", api: required }));
|
||||
console.log(callStream({ name: "one", api: withFetch }));
|
||||
console.log(callStream({ name: "registry", api: registry }));
|
||||
|
||||
// `in` through an assertion over a computed receiver still evaluates the
|
||||
// receiver, exactly once, whether the arms decide the answer (one tag test
|
||||
// or several) or agree on it.
|
||||
interface ArmA {
|
||||
a: number;
|
||||
x: number;
|
||||
}
|
||||
interface ArmB {
|
||||
b: number;
|
||||
x: number;
|
||||
}
|
||||
interface ArmC {
|
||||
c: number;
|
||||
}
|
||||
interface ArmD {
|
||||
d: number;
|
||||
}
|
||||
type Arm = ArmA | ArmB | ArmC | ArmD;
|
||||
let boxed = 0;
|
||||
function box(shape: Arm): { shape: Arm } {
|
||||
boxed++;
|
||||
return { shape };
|
||||
}
|
||||
const arms: Arm[] = [{ a: 1, x: 1 }, { b: 2, x: 2 }, { c: 3 }, { d: 4 }];
|
||||
for (const arm of arms) console.log("x" in (box(arm).shape as ArmA), "gamma" in (box(arm).shape as ArmA));
|
||||
console.log(boxed);
|
||||
|
||||
// A getter-backed member beside a data field of the same name: the probe
|
||||
// reads the getter on its arm, as Node does.
|
||||
interface Plain {
|
||||
kind: "plain";
|
||||
label: string;
|
||||
}
|
||||
interface Lazy {
|
||||
kind: "lazy";
|
||||
get label(): string;
|
||||
}
|
||||
let labelReads = 0;
|
||||
const lazy: Lazy = {
|
||||
kind: "lazy",
|
||||
get label() {
|
||||
labelReads++;
|
||||
return "computed";
|
||||
},
|
||||
};
|
||||
const labelled: (Plain | Lazy)[] = [{ kind: "plain", label: "x" }, lazy];
|
||||
for (const item of labelled) console.log(typeof (item as Plain).label, (item as Plain).label !== undefined);
|
||||
console.log(labelReads);
|
||||
@@ -0,0 +1,73 @@
|
||||
// A generic method member whose own type parameter is CONSTRAINED by the
|
||||
// enclosing type's type parameter (`run<T extends TApi>(item: Item<T>)`),
|
||||
// implemented by a plain lambda: the lambda takes the interface signature's
|
||||
// type parameters as its own and lowers once, erased like a generic lambda.
|
||||
// `Item<T>`'s conditional member depends on the type argument each call
|
||||
// passes, so the erased parameter must hold whatever the call sends.
|
||||
|
||||
type Api = "alpha" | "beta" | "gamma";
|
||||
|
||||
interface AlphaOptions {
|
||||
fast?: boolean;
|
||||
}
|
||||
interface BetaOptions {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface Item<TApi extends Api> {
|
||||
id: string;
|
||||
api: TApi;
|
||||
options?: TApi extends "alpha" ? AlphaOptions : TApi extends "beta" ? BetaOptions : never;
|
||||
}
|
||||
|
||||
interface Runner<TApi extends Api> {
|
||||
name: string;
|
||||
run<T extends TApi>(item: Item<T>): string;
|
||||
}
|
||||
|
||||
function describe(item: Item<Api>): string {
|
||||
const options = item.options === undefined ? "-" : JSON.stringify(item.options);
|
||||
return `${item.id}:${item.api}:${options}`;
|
||||
}
|
||||
|
||||
function makeRunner<TApi extends Api>(name: string): Runner<TApi> {
|
||||
return {
|
||||
name,
|
||||
run: (item) => `${name}:${describe(item)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const both = makeRunner<"alpha" | "beta">("both");
|
||||
console.log(both.name);
|
||||
console.log(both.run({ id: "a", api: "alpha", options: { fast: true } }));
|
||||
console.log(both.run({ id: "b", api: "beta", options: { depth: 3 } }));
|
||||
console.log(both.run({ id: "c", api: "alpha" }));
|
||||
console.log(both.run<"beta">({ id: "d", api: "beta" }));
|
||||
|
||||
const all = makeRunner<Api>("all");
|
||||
console.log(all.run({ id: "e", api: "gamma" }));
|
||||
console.log(all.run({ id: "f", api: "beta", options: { depth: 1 } }));
|
||||
|
||||
// The same contract through a class body: the generic method lowers under
|
||||
// the class instantiation's binding joined with its own type parameter.
|
||||
class ClassRunner<TApi extends Api> implements Runner<TApi> {
|
||||
name: string;
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
run<T extends TApi>(item: Item<T>): string {
|
||||
return `${this.name}:${describe(item)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const cls = new ClassRunner<"alpha" | "beta">("cls");
|
||||
console.log(cls.run({ id: "g", api: "alpha", options: { fast: false } }));
|
||||
console.log(cls.run({ id: "h", api: "beta" }));
|
||||
|
||||
// Record-held receivers call the erased slot the literal stored.
|
||||
const runners: Runner<"alpha" | "beta">[] = [both, cls];
|
||||
let combined = "";
|
||||
for (const runner of runners) {
|
||||
combined += `[${runner.run({ id: "i", api: "alpha" })}]`;
|
||||
}
|
||||
console.log(combined);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 5031, "san": 5482 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4100 }
|
||||
Reference in New Issue
Block a user