From ec973e722194a4bb828ec26f349be896aa210f05 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 15:22:06 -0700 Subject: [PATCH] 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(item: Item)), 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's member 'api' has type 'T'), and the pi models.ts:1159 Model 'compat' shape refused the same way. Co-Authored-By: Claude Opus 5.5 --- ...ic-member-constrained-lambda-impls.ts.json | 6 ++ ...generic-member-constrained-lambda-impls.ts | 73 +++++++++++++++++++ ...ic-member-constrained-lambda-impls.ts.json | 1 + 3 files changed, 80 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json create mode 100644 tests/corpus/generic-member-constrained-lambda-impls.ts create mode 100644 tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json new file mode 100644 index 000000000..4c7bdccc5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-member-constrained-lambda-impls.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-member-constrained-lambda-impls.ts b/tests/corpus/generic-member-constrained-lambda-impls.ts new file mode 100644 index 000000000..b34d46c15 --- /dev/null +++ b/tests/corpus/generic-member-constrained-lambda-impls.ts @@ -0,0 +1,73 @@ +// A generic method member whose own type parameter is CONSTRAINED by the +// enclosing type's type parameter (`run(item: Item)`), +// 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`'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 { + id: string; + api: TApi; + options?: TApi extends "alpha" ? AlphaOptions : TApi extends "beta" ? BetaOptions : never; +} + +interface Runner { + name: string; + run(item: Item): string; +} + +function describe(item: Item): string { + const options = item.options === undefined ? "-" : JSON.stringify(item.options); + return `${item.id}:${item.api}:${options}`; +} + +function makeRunner(name: string): Runner { + 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("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 implements Runner { + name: string; + constructor(name: string) { + this.name = name; + } + run(item: Item): 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); diff --git a/tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json b/tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json new file mode 100644 index 000000000..fe5fca1ae --- /dev/null +++ b/tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json @@ -0,0 +1 @@ +{ "plain": 4100 }