Pin generic method members constrained by the enclosing type parameter

An interface's generic method whose own type parameter is constrained by
the enclosing type's parameter (run<T extends TApi>(item: Item<T>)),
implemented by a plain contextually typed lambda, compiles since #2274:
the lambda takes the contextual signature's type parameters and lowers
once, erased. The merged corpus only covers unconstrained contextual
generics, so this program pins the constrained shape with a conditional
member on the argument type, explicit and inferred call type arguments,
the class-body form, and record-held receivers.

Verified against the 2026-10-23 base checkout, where the same shape
refused with SC2009 (Item<T>'s member 'api' has type 'T'), and the pi
models.ts:1159 Model<T> 'compat' shape refused the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-27 15:22:06 -07:00
co-authored by Claude Opus 5.5
parent 6e5f21c580
commit ec973e7221
3 changed files with 80 additions and 0 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-member-constrained-lambda-impls.ts"
],
"diags": []
}
@@ -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": 4100 }