Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,58 @@ export class ComposableController<
const controllerMessenger = ControllerMessenger<any, any>;
```

#### `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.
Comment thread
MajorLift marked this conversation as resolved.
Outdated

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 <a id="example-f2a3b7d1-9e4c-4f8a-b6c2-1d8e5a3c9f7b"></a> ([🔗 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about how this guideline is framed.

This note says that this is safe because "The enclosing external APIs re-impose their own parameter types at each use site", and indeed that is why this pattern is safe in the rare case where we've used it.

But this statement is not true of all situations where you might be tempted to use any to get around incompatible type expectations. "callback parameter types constrained by bidirectional assignment" may describe scenarios where there is no "enclosing external API" with genuinely safe types, where this error is a legitimate type error that indicates a broken design.

@Gudahtt Gudahtt Apr 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not totally sure what to suggest though. I am having trouble describing the safe scenarios here.

It's more than just a matter of encapsulation. We could encapsulate an instance of an invariant type causing a type error, and it still be a legitimate error. We need to be confident the error would never occur (e.g. we'll never attempt to pass in a parameter that doesn't match the callback parameter type, and we'll never return a value that doesn't match the callback return type)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I'll clarify that sometimes we can't "re-impose types" and any won't be safe to use.

If it's possible to re-type either constraint to resolve an incompatible configuration without affecting downstream callers or introducing semantic inaccuracies, the type error does signal broken design and we shouldn't use any to suppress it.

Will push a new draft based on this.


### Type-Only Dependencies

If package `a` imports only types from `b`, should `b` be a dev or production dependency of `a`?
Expand Down
Loading