From 77548ad12d65ba95fecd277464607f2668fff101 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Tue, 14 Apr 2026 17:22:49 -0400 Subject: [PATCH 1/2] Document `any` requirement for callback parameters constrained by bidirectional assignment --- docs/typescript.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/typescript.md b/docs/typescript.md index 8435cae..a853906 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -881,6 +881,58 @@ export class ComposableController< const controllerMessenger = ControllerMessenger; ``` +#### `any` is required for callback parameter types constrained by bidirectional assignment + +This shape arises whenever a callback value is constrained on both sides of an assignment by _different_ external function types. Real-world instances: + +- A `coerces` map sitting between a library signature and a caller's own config — see [`metamask-extension#41104 (r3045807022)`](https://github.com/MetaMask/metamask-extension/pull/41104#discussion_r3045807022). +- A messenger's `registerActionHandler` slot typed `(...args: any[]) => any`: it receives strongly-typed handler callbacks inward at registration _and_ is invoked with strongly-typed argument tuples outward at dispatch. `unknown[]` fails registration; `never[]` fails dispatch. + +Under `--strictFunctionTypes`, function parameters are checked _contravariantly_: `(arg: A) => R` is assignable to `(arg: B) => R` only when `B extends A`. Parameter types flow in the _reverse_ direction of the assignment. + +A callback (or a `Record` of callbacks) hits an impossible constraint when its parameter position is constrained by _two different_ external function types at once: + +1. **Outward** — the callback flows into a slot of another function type. Contravariance requires its parameter to be a _supertype_ of that slot's parameter. `unknown` ✓, `never` ✗. +2. **Inward** — another function value is assigned into the callback's slot. Contravariance requires its parameter to be a _subtype_ of the incoming value's parameter. `never` ✓, `unknown` ✗. + +When both directions apply to the same parameter position, no concrete type satisfies both unless one external param already extends the other — which external APIs rarely guarantee. `any` is the only inhabitant of both the top and bottom of the assignability lattice, and is the only escape. Return types remain covariant and can stay `unknown`. + +| Parameter type | Outward (supertype of outer param) | Inward (subtype of outer param) | +| -------------- | ---------------------------------- | ------------------------------- | +| `unknown` | ✓ | ✗ | +| `never` | ✗ | ✓ | +| `any` | ✓ | ✓ | + +**Example ([🔗 permalink](#example-f2a3b7d1-9e4c-4f8a-b6c2-1d8e5a3c9f7b)):** + +A callback's parameter constrained from both sides — outward by a wider external type, inward by a narrower one: + +```typescript +type Wide = (x: string) => void; +type Narrow = (x: 'a') => void; + +declare function takesWide(f: Wide): void; +declare const givesNarrow: Narrow; + +// 🚫 `unknown` — satisfies outward, fails inward +let f1: (x: unknown) => void; +takesWide(f1); // ✓ +f1 = givesNarrow; // ✗ 'unknown' not assignable to '"a"' + +// 🚫 `never` — satisfies inward, fails outward +let f2: (x: never) => void; +f2 = givesNarrow; // ✓ +takesWide(f2); // ✗ 'string' not assignable to 'never' + +// ✅ `any` — satisfies both directions +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let f3: (x: any) => void; +takesWide(f3); // ✓ +f3 = givesNarrow; // ✓ +``` + +> **Note:** The `eslint-disable` is intentional. `any` here is _not_ infectious: it is scoped to a single callback's parameter position and does not propagate to callers. The enclosing external APIs re-impose their own parameter types at each use site, so type safety is preserved where values actually flow. Prefer `unknown` or `never` when only one direction applies; reach for `any` only when both apply to the same parameter position. + ### Type-Only Dependencies If package `a` imports only types from `b`, should `b` be a dev or production dependency of `a`? From 784977672cbffb13cb709d27a1287ad152561633 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Tue, 14 Apr 2026 17:23:50 -0400 Subject: [PATCH 2/2] Reorder examples --- docs/typescript.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/typescript.md b/docs/typescript.md index a853906..44895fa 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -885,8 +885,8 @@ export class ComposableController< This shape arises whenever a callback value is constrained on both sides of an assignment by _different_ external function types. Real-world instances: -- A `coerces` map sitting between a library signature and a caller's own config — see [`metamask-extension#41104 (r3045807022)`](https://github.com/MetaMask/metamask-extension/pull/41104#discussion_r3045807022). - A messenger's `registerActionHandler` slot typed `(...args: any[]) => any`: it receives strongly-typed handler callbacks inward at registration _and_ is invoked with strongly-typed argument tuples outward at dispatch. `unknown[]` fails registration; `never[]` fails dispatch. +- A `coerces` map sitting between a library signature and a caller's own config — see [`metamask-extension#41104 (r3045807022)`](https://github.com/MetaMask/metamask-extension/pull/41104#discussion_r3045807022). Under `--strictFunctionTypes`, function parameters are checked _contravariantly_: `(arg: A) => R` is assignable to `(arg: B) => R` only when `B extends A`. Parameter types flow in the _reverse_ direction of the assignment.