Read a generic constraint member the instantiation omits

`options.sessionId` inside a generic function whose type parameter's
constraint declares `sessionId?: string` fenced with SC1090 when the call
site's options record has no such slot:

  function applyAuth<TModel, TOptions extends ProviderRequestOptions<TModel> & Transforms>(
    model: TModel, options: TOptions | undefined) {
    const sessionId =
      options && "sessionId" in options && typeof options.sessionId === "string"
        ? options.sessionId : undefined;
  }
  applyAuth(model, { apiKey, azureApiVersion });

The checker admits the read through the constraint (and, with the `in`
guard, through a synthetic `T & Record<"sessionId", unknown>`), while the
instantiated record — the value's actual layout — stores no such field.
`fieldTarget` found no record slot and the read fell through to the
generic unsupported-property fence, even though Node simply reads the
property off the object it has: undefined when the caller omitted it, the
extra property when a JavaScript caller supplied one.

fieldTarget now recognizes a generic receiver (a type parameter, or an
`in`-narrowed intersection that contains one) whose stored record lacks
the member, and lowers the read as the checked-dynamic keyed read of that
record value, entering the static code at the type the checker gave the
read. Reads that keep a slot take their record get as before, and the
programs this path opens did not compile at all before, so typed code
keeps its representation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:14 -07:00
co-authored by Claude Opus 5.5
parent 04409081f9
commit b200bde558
5 changed files with 225 additions and 0 deletions
@@ -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;
@@ -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<TModel> { model?: TModel; apiKey?: string; sessionId?: string }",
"interface Transforms {",
" transformHeaders?: (headers: Record<string, string | null>) => Record<string, string | null>;",
"}",
"function sessionOf<TModel, TOptions extends RequestOptions<TModel> & 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"');
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-constraint-member-read.ts"
],
"diags": []
}
@@ -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<TModel> {
model?: TModel;
apiKey?: string;
sessionId?: string;
}
interface Transforms {
transformHeaders?: (headers: Record<string, string | null>) => Record<string, string | null>;
}
function describeOptions<TModel, TOptions extends RequestOptions<TModel> & Transforms>(
options: TOptions | undefined,
): string {
const sessionId =
options && "sessionId" in options && typeof options.sessionId === "string"
? options.sessionId
: "<none>";
const apiKey =
options && "apiKey" in options && typeof options.apiKey === "string" ? options.apiKey : "<no-key>";
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<TOptions extends RequestOptions<string> & Transforms> {
read(options: TOptions | undefined): string {
return options && "sessionId" in options && typeof options.sessionId === "string"
? options.sessionId
: "<none>";
}
}
const keyedReader = new OptionsReader<typeof withSession>();
const bareReader = new OptionsReader<typeof withoutSession>();
console.log(
keyedReader.read(withSession),
bareReader.read(withoutSession),
bareReader.read(undefined),
);
@@ -0,0 +1 @@
{ "plain": 2500, "san": 3400 }