Monomorphize generic overload calls and call generic methods through Pick views (#1511)

This commit is contained in:
Chris Tate
2026-10-20 12:33:30 -05:00
committed by GitHub
17 changed files with 330 additions and 30 deletions
@@ -268,10 +268,13 @@ export function genericCallInstance(
{
const rdecl = rsig ? rsig.declaration : undefined;
if (
rsig &&
rdecl &&
(ts.isFunctionDeclaration(rdecl) || ts.isMethodDeclaration(rdecl)) &&
!rdecl.body
) {
const overload = overloadCallInstance(lowerer, expr, info, rsig, rdecl);
if (overload) return overload;
lowerer.unsupported(
"SC1090",
expr,
@@ -317,23 +320,11 @@ export function genericCallInstance(
// different fields). Non-literal bindings (a key union, plain string)
// share one runtime-keyed instance per IR signature, exactly the
// widened discipline.
const keyofTps = keyofConstrainedTypeParams(info);
if (keyofTps.size > 0) {
if (keyofConstrainedTypeParams(info).size > 0) {
const tsBindings = new Map<ts.Symbol, ts.Type>();
const bindings = lowerer.inferTypeParamBindings(expr, info, rsig, tsBindings);
const litKey = info.typeParams
.filter((tp) => keyofTps.has(tp))
.map((tp) => {
const bound = tsBindings.get(tp);
return bound?.isStringLiteralType()
? JSON.stringify(bound.value)
: bound?.isNumberLiteralType()
? String(bound.value)
: "*";
})
.join(",");
return internGenericInstance(lowerer, expr, info, params, returnType, () => bindings, {
extraKey: `@${litKey}`,
extraKey: keyofLiteralKey(info, tsBindings),
tsBindings,
});
}
@@ -342,6 +333,98 @@ export function genericCallInstance(
);
}
/** A call that resolved to an OVERLOAD signature of a generic function
* or method with an implementation (`request<T>(p: WithHandler<T>):
* Promise<T>; request(p: Plain): Promise<Res>; request<T>(p: Params<T>)
* {...}`): the implementation monomorphizes under the bindings the
* overload's resolved signature proves for its type parameters (the
* structural unification every generic call uses, run against the
* overload; unbound parameters take their declared defaults). The
* instance's parameters and result are the IMPLEMENTATION's under those
* bindings — the body was checked against them — and the call's
* arguments and result coerce across, exactly as a non-generic overload
* call reconciles with its implementation (parameters the overload omits
* must be optional in the implementation). Null when the overload does
* not belong to this implementation or the arities do not line up. */
function overloadCallInstance(
lowerer: Lowerer,
expr: ts.CallExpression,
info: GenericFnInfo,
rsig: ts.Signature,
rdecl: ts.FunctionDeclaration | ts.MethodDeclaration,
): GenericInstance | null {
const impl = info.decl;
if (
rdecl.parent !== impl.parent ||
rdecl.name === undefined ||
impl.name === undefined ||
rdecl.name.getText() !== impl.name.getText() ||
rdecl.parameters.nodes.length > impl.parameters.nodes.length ||
impl.parameters.nodes
.slice(rdecl.parameters.nodes.length)
.some((p) => p.questionToken === undefined && p.initializer === undefined) ||
rdecl.parameters.nodes.some((p) => p.dotDotDotToken !== undefined)
)
return null;
const declSig = lowerer.checker.getSignatureFromDeclaration(impl);
if (!declSig) return null;
const tsBindings = new Map<ts.Symbol, ts.Type>();
const bindings = new Map<ts.Symbol, IrType>();
unifySignatureBindings(lowerer, info, rsig, bindings, tsBindings);
// Explicit type arguments name the OVERLOAD's type parameters, which
// need not match the implementation's in order: the resolved overload
// already carries them, so unification binds first, and an explicit
// argument binds only a still-unbound implementation parameter of the
// same name.
const overloadTps = rdecl.typeParameters?.nodes ?? [];
expr.typeArguments?.nodes.forEach((ta, i) => {
const name = overloadTps[i]?.name.text;
const tp = info.typeParams.find((p) => p.name === name);
if (!tp || bindings.has(tp)) return;
const taT = lowerer.checker.getTypeFromTypeNode(ta);
const mapped = lowerer.mapTypeOf(taT);
if (mapped) {
bindings.set(tp, mapped);
tsBindings.set(tp, taT);
}
});
bindDefaultTypeParams(lowerer, info.typeParams, impl.typeParameters?.nodes, bindings, tsBindings);
// A type parameter the overload never mentions (a handler the overload
// omits) is `unknown`, the checker's own answer for an uninferred one.
for (const tp of info.typeParams) {
if (bindings.has(tp)) continue;
bindings.set(tp, DYN);
tsBindings.set(tp, lowerer.checker.getUnknownType());
}
const previous = lowerer.typeParamBindings;
const previousTs = lowerer.typeParamTsBindings;
lowerer.typeParamBindings = bindings;
lowerer.typeParamTsBindings = tsBindings;
let params: ParamShape[];
let returnType: IrType | null;
try {
params = paramShapes(
lowerer,
impl.parameters.nodes,
declSig,
(_param, i) => expr.arguments.nodes[i] ?? expr,
);
returnType = lowerer.mapTypeOf(lowerer.checker.getReturnTypeOfSignature(declSig));
} finally {
lowerer.typeParamBindings = previous;
lowerer.typeParamTsBindings = previousTs;
}
if (!returnType) return null;
// keyof-constrained parameters key their instance on the bound literal,
// as on the implementation-signature path.
return internGenericInstance(lowerer, expr, info, params, returnType, () => bindings, {
...(keyofConstrainedTypeParams(info).size > 0
? { extraKey: keyofLiteralKey(info, tsBindings) }
: {}),
tsBindings,
});
}
/** A generic factory whose result is one of its own local classes
* (`function base<T>(v: T) { return class { value: T = v }; }`). Each
* instance has its own class, so the instance is keyed on its bindings and
@@ -570,6 +653,25 @@ function keyofConstrainedTypeParams(info: GenericFnInfo): Set<ts.Symbol> {
return out;
}
/** The instance-key suffix of the literals bound to `info`'s
* keyof-constrained type parameters (`@"a"`; `*` for a non-literal
* binding): two literals can map to one IR signature while their bodies
* read different fields. */
function keyofLiteralKey(info: GenericFnInfo, tsBindings: Map<ts.Symbol, ts.Type>): string {
const keyofTps = keyofConstrainedTypeParams(info);
const literals = info.typeParams
.filter((tp) => keyofTps.has(tp))
.map((tp) => {
const bound = tsBindings.get(tp);
return bound?.isStringLiteralType()
? JSON.stringify(bound.value)
: bound?.isNumberLiteralType()
? String(bound.value)
: "*";
});
return `@${literals.join(",")}`;
}
/** Type-parameter symbol → concrete IR type for one instantiation.
* Explicit type arguments bind directly; the rest come from structurally
* matching each DECLARED param/return type (which mentions the type
@@ -705,6 +807,27 @@ function unifySignatureBindings(
// so match it against the whole remaining union. Removing null
// too is safe only when no undeclared null arm remains.
unify(dParts[0]!, lowerer.checker.getNonNullableType(inst), depth + 1);
} else if (dParts.length > 1 && iParts.length === dParts.length) {
// `T | Promise<T>` against `Res | Promise<Res>`: each declared
// part that names a generic target (Promise, an interface, an
// alias) pairs with the one instantiated part of the same target;
// a single remaining bare type parameter takes the single
// remaining instantiated part.
const remaining = [...iParts];
const bare: ts.Type[] = [];
for (const d of dParts) {
if (d.flags & ts.TypeFlags.TypeParameter) {
bare.push(d);
continue;
}
const target = d.getSymbol() ?? d.getAliasSymbol();
if (target === undefined) return;
const matches = remaining.filter((i) => (i.getSymbol() ?? i.getAliasSymbol()) === target);
if (matches.length !== 1) return;
remaining.splice(remaining.indexOf(matches[0]!), 1);
unify(d, matches[0]!, depth + 1);
}
if (bare.length === 1 && remaining.length === 1) unify(bare[0]!, remaining[0]!, depth + 1);
}
return;
}
@@ -3309,6 +3309,8 @@ function lowerDynReceiverMethodCall(
// any-elemented array). Typed receivers keep their own lowerings.
const recvTs = lowerer.typeOf(access.expression);
if (lowerer.classImplementedProtocol(recvTs)) {
const viewed = pickViewGenericMethodCall(lowerer, call, access, recvTs);
if (viewed) return viewed;
const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text);
if (
property &&
@@ -10092,3 +10094,49 @@ function maybePromiseHandlerResult(
}
return stmt;
}
/** `view.m(args)` where `view` is a `Pick<C, K>` / `Omit<C, K>` view of a
* program class C and `m` is one of C's GENERIC methods (its overloads
* included): generic methods have no dispatchable method value, so the
* view's live receiver is extracted as the C instance it holds (a checked
* extraction — a value of another class throws Node-style TypeError
* text naming C) and the call monomorphizes statically against C, the
* same lowering a C-typed receiver gets. Null for other receivers. */
function pickViewGenericMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
recvTs: ts.Type,
): IrExpr | null {
const alias = recvTs.getAliasSymbol();
if (alias === undefined || (alias.name !== "Pick" && alias.name !== "Omit")) return null;
if (!alias.declarations.every((d) => d.getSourceFile().isDeclarationFile)) return null;
const source = recvTs.getAliasTypeArguments()[0];
const symbol = source?.getSymbol();
const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined;
if (!info || call.questionDotToken || access.questionDotToken) return null;
const found = findGenericMethodOn(lowerer, info, access.name.text);
if (!found) return null;
const loc = locOf(access.expression);
const lowered = lowerer.lowerExpr(access.expression);
const instance: IrType = { kind: "object", className: info.def.name };
// A view that statically holds an instance of another class (a
// structurally compatible one) calls that class's method in Node, not
// C's: the call is refused rather than extracted as a C instance.
if (
lowered.type.kind === "object" &&
lowered.type.className !== info.def.name &&
!lowerer.isSubclassOf(lowered.type.className, info.def.name)
) {
lowerer.unsupported(
"SC1090",
access,
`calling the generic method '${access.name.text}' through a '${alias.name}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`,
);
}
const receiver: IrExpr =
lowered.type.kind === "object"
? lowered
: { kind: "dynCheck", value: lowerer.coerceToExpected(lowered, DYN), type: instance, loc };
return lowerClassGenericMethodCall(lowerer, call, access, info, found, receiver);
}
@@ -6340,6 +6340,10 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean {
return true;
case "record":
case "array":
// `T | Promise<T>` with T bound to `unknown`: the checked-dynamic value
// holds a promise by reference, so `unknown` absorbs the promise arm
// exactly as the checker's own union reduction does.
case "promise":
return canConvertToDyn(
arm,
(id) => ctx.shapes.get(id),
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6495-generic-method-overloads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6496-overload-signatures.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6497-generic-overload-bindings.ts"
],
"diags": []
}
@@ -1,6 +0,0 @@
{
"order": [
"<repo>/tests/diagnostics/overload-fences.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/pick-view-generic-methods.ts"
],
"diags": []
}
@@ -0,0 +1,54 @@
// Calls that resolve to OVERLOAD signatures of a generic method or
// function: the implementation monomorphizes under what the overload
// proves for its type parameters (a parameter the overload never mentions
// is unknown), directly and through a `Pick` view of the class, whose
// live receiver keeps the instance's state. `T | Promise<T>` callbacks
// bind T from either arm.
interface Reply {
code: number;
text: string;
}
type Mapper<T> = (reply: Reply) => T | Promise<T>;
class Store {
loads = 0;
async load<T>(key: string, map: Mapper<T>): Promise<T>;
async load(key: string): Promise<Reply>;
async load<T>(key: string, map?: Mapper<T>): Promise<T | Reply> {
this.loads++;
const reply: Reply = { code: 200, text: key };
if (map === undefined) return reply;
return await map(reply);
}
}
interface Services {
store: Pick<Store, "load">;
}
function pick<T>(items: T[], index: number): T;
function pick<T>(items: T[]): T;
function pick<T>(items: T[], index = 0): T {
return items[index]!;
}
async function settle<T>(reply: Reply, map?: Mapper<T>): Promise<T | Reply> {
return map ? await map(reply) : reply;
}
async function main(): Promise<void> {
const store = new Store();
const plain = await store.load("alpha");
console.log(plain.code, plain.text);
const length = await store.load("beta", (reply) => reply.text.length);
console.log(length * 2);
const services: Services = { store };
const viaView = await services.store.load("gamma");
const upper = await services.store.load("delta", async (reply) => reply.text.toUpperCase());
console.log(viaView.text, upper, store.loads);
console.log(pick(["x", "y"]), pick([1, 2, 3], 2));
console.log(JSON.stringify(await settle<Reply>({ code: 1, text: "s" })));
console.log(await settle({ code: 2, text: "tt" }, (reply) => reply.code + reply.text.length));
}
main();
@@ -1,7 +1,7 @@
// The overload fences that REMAIN now that overload signatures lower:
// generic overload calls (the per-instantiation body would be typed
// against a signature it was never checked under), and the multi-
// signature type surfacing where a binding needs it spelled.
// Overload signatures: a generic overload call monomorphizes the
// implementation under the bindings the overload proves (a parameter the
// overload omits is optional in the implementation), and an immutable
// alias of an overloaded declaration projects onto the implementation.
function twice<T>(x: T): T[];
function twice<T>(x: T, n: number): T[];
function twice<T>(x: T, n?: number): T[] {
@@ -0,0 +1,34 @@
// Bindings of generic overload calls: explicit type arguments name the
// overload's type parameters, which can be declared in another order than
// the implementation's, and keyof-constrained parameters bound to
// different literals through an overload read their own fields.
function tag<B, A>(value: A, label: B): string;
function tag<A, B>(value: A, label?: B): string {
return `${typeof value}:${typeof label}:${String(value)}${String(label)}`;
}
console.log(tag<string, number>(5, "x"));
console.log(tag<boolean, string>("y", true));
class Labels {
prefix = "#";
render<B, A>(value: A, label: B): string;
render<A, B>(value: A, label?: B): string {
return `${this.prefix}${typeof value}/${typeof label}`;
}
}
console.log(new Labels().render<number, string>("z", 7));
interface Row {
a: number;
b: number;
name: string;
}
function read<T, K extends keyof T>(row: T, key: K): T[K];
function read<T, K extends keyof T>(row: T, key: K, fallback: T[K]): T[K];
function read<T, K extends keyof T>(row: T, key: K, fallback?: T[K]): T[K] {
const value = row[key];
return value === undefined ? fallback! : value;
}
const row: Row = { a: 1, b: 2, name: "r" };
console.log(read(row, "a"), read(row, "b"), read(row, "name"));
console.log(read(row, "b", 0) + read(row, "a", 0));
@@ -0,0 +1,16 @@
// A generic method called through a `Pick` view of a class compiles
// against that class. A view that holds an instance of a structurally
// compatible, unrelated class is refused: Node calls that class's own
// method.
class Store {
load<T>(key: string, map: (text: string) => T): T {
return map(key);
}
}
class Mirror {
load<T>(key: string, map: (text: string) => T): T {
return map(`${key}!`);
}
}
const view: Pick<Store, "load"> = new Mirror();
console.log(view.load("k", (text) => text.length));
@@ -1,6 +0,0 @@
overload-fences.ts:12:13 - error SC1090: calls selecting an overload signature of a generic function (only the implementation signature monomorphizes) is not supported yet
11 | }
12 | console.log(twice("q").length);
| ^~~~~~~~~~
13 | function pick(x: "a"): string;
@@ -0,0 +1,6 @@
pick-view-generic-methods.ts:16:13 - error SC1090: calling the generic method 'load' through a 'Pick' view of 'Store' that holds a 'Mirror' is not supported yet
15 | const view: Pick<Store, "load"> = new Mirror();
16 | console.log(view.load("k", (text) => text.length));
| ^~~~~~~~~
17 |
@@ -0,0 +1 @@
{ "plain": 2783, "san": 2211 }
@@ -0,0 +1 @@
{ "plain": 2032, "san": 1924 }
@@ -0,0 +1 @@
{ "plain": 2061, "san": 1900 }