diff --git a/docs/typescript.md b/docs/typescript.md index 8435cae..50f8573 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -881,6 +881,94 @@ export class ComposableController< const controllerMessenger = ControllerMessenger; ``` +#### `any` is acceptable for callback parameters caught between two irresolvable function-type constraints + +A callback's parameters may be typed as `any` if three conditions all hold: + +1. **Bivariant position:** The callback must be _assignable_ to a wider function type and an _assignee_ of a narrower function type. +2. **Irresolvable constraints:** No concrete type satisfies both directions, because the "wider" function type is not a supertype of the "narrower" function type, which creates a paradox. +3. **Fixed constraints:** Neither constraint can be redesigned without affecting downstream callers or introducing semantic or structural inaccuracies. + +🚫 When the constraints are redesignable, the contravariance error should not be suppressed with `any`, as it legitimately signals a broken design: + +**Example ([🔗 permalink](#example-a3c5e7f1-8b2d-4a6c-9e0f-1b3d5e7a9c2b)):** + +```typescript +// 🚫 Both constraints are internal. `any` masks a fixable design +type Slot = (handler: (event: unknown) => void) => void; +type Handler = (event: { kind: 'a' }) => void; + +declare const accept: Slot; +declare const onA: Handler; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const bridge: (event: any) => void = onA; +accept(bridge); + +// ✅ Fix: parametrize the slot so the bivariant pressure disappears +declare const acceptGeneric: (handler: (event: E) => void) => void; +acceptGeneric(onA); // no `any` needed +``` + +✅ When the constraints are fixed and irresolvable, `Wide` and `Narrow` stand in for function types you cannot redesign: + +**Example ([🔗 permalink](#example-f2a3b7d1-9e4c-4f8a-b6c2-1d8e5a3c9f7b)):** + +```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 -- Bivariant position with irresolvable, fixed constraints +let f3: (x: any) => void; +takesWide(f3); // ✓ +f3 = givesNarrow; // ✓ +``` + +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. Both constraint types re-impose their own signatures at each use site, so type safety is preserved where values actually flow. Annotate `eslint-disable` directives with a comment naming the conditions, so reviewers can evaluate the override against the criteria above. + +The safety claim is conditional on the constraint types being accurate. If a fixed constraint is imprecise (a library type that uses `any` internally, or an internal type with embedded assertions), the bridge `any` is still mechanically necessary, but type safety is not fully preserved at use sites because the constraints do not enforce what they claim. + +Real-world instances: + +- 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. The slot encodes [rank-N polymorphism](https://www.microsoft.com/en-us/research/publication/practical-type-inference-for-arbitrary-rank-types/) (`∀α. (α) => R`) via `any` because TypeScript lacks first-class universal quantification. The registry's heterogeneity requirement makes the wide slot structurally fixed regardless of ownership. +- 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)). + +
+Why bivariant pressure forces any (contravariance derivation) + +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. + +Bivariant pressure on a parameter creates two contravariant requirements at once: + +1. **Outward** — the callback flows into another function-type slot. The callback's parameter must be a _supertype_ of that slot's parameter (`unknown` ✓, `never` ✗). +2. **Inward** — another function value is assigned into the callback's slot. The callback's parameter must be a _subtype_ of the incoming value's parameter (`never` ✓, `unknown` ✗). + +A concrete type T satisfies both only when `WideParam extends T extends NarrowParam`, which requires `WideParam extends NarrowParam`. When the two constraint types do not stand in this subtype relationship (the irresolvable case), no concrete T works. `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` | ✓ | ✓ | + +
+ +Bivariant pressure can occur for any type, but it is especially relevant for callback parameters. Other invariant positions (e.g., a `ReadWrite` container used in both read and write contexts) can usually be dissolved by introducing a generic, so irresolvability rarely occurs. + ### Type-Only Dependencies If package `a` imports only types from `b`, should `b` be a dev or production dependency of `a`?