mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
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:
co-authored by
Claude Opus 5.5
parent
04409081f9
commit
b200bde558
@@ -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"');
|
||||
});
|
||||
+6
@@ -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 }
|
||||
Reference in New Issue
Block a user