Pin constrained generic members and probe through assertions (#2443)

This commit is contained in:
Chris Tate
2026-10-27 21:27:21 -05:00
committed by GitHub
11 changed files with 619 additions and 35 deletions
@@ -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/);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/asserted-arm-probe.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-member-constrained-lambda-impls.ts"
],
"diags": []
}
+133
View File
@@ -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 }