diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 93ec89099..3c0a8de3c 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4315,6 +4315,34 @@ function checkedDynamicValue(expr: IrExpr): IrExpr | null { return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null; } +/** A GENERIC receiver type: a type parameter, or a narrowing of one that + * intersects it (`"k" in options` refines a type variable to + * `T & Record<"k", unknown>`). Its stored layout is whatever the call site + * instantiated it with, and the CONSTRAINT's members are not necessarily + * stored there. */ +function typeParameterReceiver(t: ts.Type): boolean { + try { + if ((t.flags & ts.TypeFlags.TypeParameter) !== 0) return true; + return ( + (t.flags & ts.TypeFlags.Intersection) !== 0 && + ts.constituentTypes(t).some((part) => (part.flags & ts.TypeFlags.TypeParameter) !== 0) + ); + } catch (e) { + if (ts.isUnreadableConstituentsPanic(e)) return false; + throw e; + } +} + +/** Whether `access` is only READ (not the target of an assignment, a + * delete, or an increment). */ +function readOnlyAccess(access: ts.PropertyAccessExpression): boolean { + const parent = access.parent; + if (parent === undefined) return true; + if (ts.isBinaryExpression(parent) && parent.left === access) return false; + if (ts.isDeleteExpression(parent)) return false; + return !ts.isPrefixUnaryExpression(parent) && !ts.isPostfixUnaryExpression(parent); +} + export function fieldTarget( lowerer: Lowerer, access: ts.PropertyAccessExpression, @@ -4441,6 +4469,55 @@ export function fieldTarget( lowerer.lowerExpr(access.expression), ); } + // A generic receiver's member the INSTANTIATION cannot store: the type + // parameter's constraint declares the member (so tsc admits the read), + // while the record the parameter was instantiated with has no such slot — + // a generic options bag read through its constraint (`options.sessionId` + // under an `"sessionId" in options` guard) at a call site whose options + // literal omits the optional member. Node reads the property off the + // object it actually has: undefined when the caller omitted it, the extra + // property when a JavaScript caller supplied one. So the read is the + // checked-dynamic keyed read of the stored record, entering the static + // code at the type the checker gave the read. + if ( + !access.questionDotToken && + readOnlyAccess(access) && + typeParameterReceiver(lowerer.typeOf(access.expression)) + ) { + const storedIr = stored ?? narrowed; + const arm = storedIr?.kind === "union" ? lowerer.stripUndefinedArm(storedIr) : storedIr; + const shape = arm?.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined; + const admitted = lowerer.mapTypeOf(lowerer.typeOf(access)); + if ( + shape && + !shape.tuple && + !shape.indexValue && + !shape.fields.some((f) => f.name === access.name.text) && + admitted && + admitted.kind !== "void" + ) { + let obj = lowerer.lowerExpr(access.expression); + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { + const present = lowerer.stripUndefinedArm(obj.type); + obj = + present.kind === "record" + ? (lowerer.runtimeOptionalPropertyReceiver( + access.expression, + obj, + present, + access.name.text, + ) ?? obj) + : lowerer.maybeNarrow(obj, access.expression); + } + if (obj.type.kind !== "record") return null; + return { + container: "dynamic", + obj: lowerer.coerceToExpected(obj, DYN), + field: access.name.text, + fieldType: admitted, + }; + } + } if (receiverIr?.kind === "record") { const shape = lowerer.shapes.get(receiverIr.shapeId); const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type; diff --git a/packages/compiler/test/generic-constraint-member-read-emission.test.ts b/packages/compiler/test/generic-constraint-member-read-emission.test.ts new file mode 100644 index 000000000..bb7fa7930 --- /dev/null +++ b/packages/compiler/test/generic-constraint-member-read-emission.test.ts @@ -0,0 +1,87 @@ +// A member the checker admits only through a type parameter's constraint +// while the instantiation's record has no slot for it (`options.sessionId` +// under an `"sessionId" in options` guard, at a call site whose options +// literal omits the optional member): the read must lower to the +// checked-dynamic keyed read of the stored record — the object's own +// property, undefined when absent — instead of fencing. +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface RequestOptions { model?: TModel; apiKey?: string; sessionId?: string }", + "interface Transforms {", + " transformHeaders?: (headers: Record) => Record;", + "}", + "function sessionOf & Transforms>(", + " options: TOptions | undefined,", + "): string | undefined {", + " return options && 'sessionId' in options && typeof options.sessionId === 'string'", + " ? options.sessionId", + " : undefined;", + "}", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + lines: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-generic-constraint-read-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...lines].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("an omitted constraint member reads dynamically off the stored record", async () => { + const compiled = await compileToIr([ + "const withoutKey = { apiKey: 'k' };", + "console.log(String(sessionOf<{ id: string }, typeof withoutKey>(withoutKey)));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const body = bodies(compiled.module!, "sessionOf"); + // The stored record has no `sessionId` slot, so the read is the + // checked-dynamic keyed read of the value (dynFrom → key read → check). + expect(body).toContain('"dynFrom"'); + expect(body).toContain('"dynCheck"'); + expect(body).not.toContain('"unsupported"'); +}); + +test("a constraint member the instantiation stores still reads its slot", async () => { + const compiled = await compileToIr([ + "const withKey = { apiKey: 'k', sessionId: 's-1' };", + "console.log(String(sessionOf<{ id: string }, typeof withKey>(withKey)));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const body = bodies(compiled.module!, "sessionOf"); + expect(body).toContain('"recordGet"'); + expect(body).toContain('"sessionId"'); + expect(body).not.toContain('"dynFrom"'); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json new file mode 100644 index 000000000..903bc4c50 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-constraint-member-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-constraint-member-read.ts b/tests/corpus/generic-constraint-member-read.ts new file mode 100644 index 000000000..e958097fd --- /dev/null +++ b/tests/corpus/generic-constraint-member-read.ts @@ -0,0 +1,54 @@ +// A generic function reads OPTIONAL members its constraint declares while the +// call site's own options record omits them (`options.sessionId` under an +// `"sessionId" in options` guard). Node reads the property off the object it +// actually has, so a caller that omits the member answers undefined and one +// that supplies it answers the value — for every instantiation of the +// function, which the program pins one by one. +interface RequestOptions { + model?: TModel; + apiKey?: string; + sessionId?: string; +} +interface Transforms { + transformHeaders?: (headers: Record) => Record; +} + +function describeOptions & Transforms>( + options: TOptions | undefined, +): string { + const sessionId = + options && "sessionId" in options && typeof options.sessionId === "string" + ? options.sessionId + : ""; + const apiKey = + options && "apiKey" in options && typeof options.apiKey === "string" ? options.apiKey : ""; + return `session=${sessionId} key=${apiKey}`; +} + +const withSession = { apiKey: "sk-1", sessionId: "sess-1" }; +const withoutSession = { apiKey: "sk-2" }; +const onlySession = { sessionId: "sess-3" }; +const empty = {}; + +console.log(describeOptions<{ id: string }, typeof withSession>(withSession)); +console.log(describeOptions<{ id: string }, typeof withoutSession>(withoutSession)); +console.log(describeOptions<{ id: number }, typeof onlySession>(onlySession)); +console.log(describeOptions<{ id: number }, typeof empty>(empty)); +console.log(describeOptions<{ id: string }, Transforms>(undefined)); + +// The same read through a generic method on a class, whose instantiation is +// fixed by the call site's own argument type. +class OptionsReader & Transforms> { + read(options: TOptions | undefined): string { + return options && "sessionId" in options && typeof options.sessionId === "string" + ? options.sessionId + : ""; + } +} +const keyedReader = new OptionsReader(); +const bareReader = new OptionsReader(); +console.log( + keyedReader.read(withSession), + bareReader.read(withoutSession), + bareReader.read(undefined), +); diff --git a/tests/harness/corpus-costs/generic-constraint-member-read.ts.json b/tests/harness/corpus-costs/generic-constraint-member-read.ts.json new file mode 100644 index 000000000..cb50f798b --- /dev/null +++ b/tests/harness/corpus-costs/generic-constraint-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3400 }