Skip to content

Commit a356bc3

Browse files
MajorLiftGudahttmcmire
authored
[base-controller] Fix all any usage, apply universal supertype for functions (#1890)
## Motivation The `ControllerMessenger` pattern that is used in all of our controllers currently relies on unsafe typing, which includes `any` usage. A more rigorous overhaul of these definitions will help us implement features with more type-safe and robust code. ## Explanation ### Main - Removes all `any` usage from `Action`, `Event` typing in `ControllerMessenger`. - Types `Action['handler']` with correct universal supertype for functions. - Fixes `SelectorFunction`, `EventSubscriptionMap` typing that was causing errors. - Improves `BaseController` typing by defining `NamespacedName` type: the narrowest supertype of all names defined within a given namespace. - `BaseController` now expects a "getState" action type and "stateChange" event type in all controllers. ### Auxiliary - Improved typing for `deriveStateFromMetadata` - ~Rename all single-letter type param names to be more descriptive.~ (moved here: #2030) ### Notes on the "universal function supertype" #### A. `(...args: never[]) => unknown` `Action['handler'] extends ActionConstraint['handler']`, and in general `Subtype extends Supertype`. Therefore, in order to assign any function to `Action['handler']` without restrictions, we need `ActionConstraint['handler']` to be the widest possible function i.e. the supertype of all functions. To achieve this, the return type of this supertype function needs to be `unknown` (universal supertype), and the parameters need to be `never` (universal subtype). This is because in general `(x: SuperType) => T` is a *subtype* of `(x: SubType) => T`. So the params need to be the narrowest type (i.e. `never`) for the function type to be widened into the universal function supertype. - There's an example of this in the [TypeScript docs](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#inferring-within-conditional-types). - And here's a more detailed [reddit comment](https://www.reddit.com/r/typescript/comments/muyl55/comment/gv9ndij/?utm_source=share&utm_medium=web2x&context=3) on this topic. #### B. `(...args: never) => unknown` In general, array types of arbitrary length are not assignable to tuple types that have fixed types designated to some or all of its index positions (the array is wider than the tuple since it has less constraints). This means that there's a whole subcategory of functions that `(...args: never[]) => unknown` doesn't cover: functions that have a finite number of parameters that are strictly ordered and typed, making their spread argument list evaluate as a fixed-length or variadic **tuple** type, instead of a freely expandable (or contractable) **array** type. That is, `(...args: never[]) => unknown` covers the cases where all of the params can be typed together as a group e.g. `(...args: (string | number)[]) => T`. But to cover cases like `(a: string, b: number, ...rest: unknown[]) => T`, we need another top type with an even narrower type for the params: `(...args: never) => unknown`. #### C. `((...args: never) => unknown) | ((...args: never[]) => unknown)` In short, the above represents the top type that includes both of these function categories. - `| never` in general is analogous to `+ 0`, so `(...args: (never[] | never)) => unknown` doesn't work. - `(...args: never) => unknown` doesn't work with the error message `Type 'T[]' is not assignable to type 'never'`. ## References - Closes #2026 ## Changelog ### `@metamask/base-controller` - **CHANGED**: `ActionConstraint['handler']` has been fixed to remove `any` usage, and is now defined as the universal supertype of all functions, meaning any function can be safely assigned to it, regardless of argument types, number of arguments, or return value type. - **CHANGED** : `ExtractActionParameters` and `ExtractActionResponse` have also been fixed to remove `any` usage, and to use the new typing of `ActionConstraint['handler']`. - **CHANGED**: **BREAKING** Alters `SelectorFunction` type, replacing its `Args` generic parameter with an event payload type that is defined with a new required generic parameter `Event`. - **ADDED**: Exports `NamespacedName` type, which is the narrowest supertype of all names defined within a given namespace. ### `@metamask/rate-limit-controller` - **CHANGED**: **BREAKING** `RateLimitedApi['method']` is now constrained by the `ActionConstraint['handler']` type from the `@metamask/base-controller` module. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Mark Stacey <[email protected]> Co-authored-by: Elliot Winkler <[email protected]>
1 parent 07224cf commit a356bc3

File tree

3 files changed

+114
-47
lines changed

3 files changed

+114
-47
lines changed

packages/base-controller/src/BaseControllerV2.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { enablePatches, produceWithPatches, applyPatches } from 'immer';
33
import type { Draft, Patch } from 'immer';
44

55
import type {
6+
ActionConstraint,
7+
EventConstraint,
68
RestrictedControllerMessenger,
7-
Namespaced,
89
} from './ControllerMessenger';
910

1011
enablePatches();
@@ -62,13 +63,45 @@ export interface StatePropertyMetadata<T extends Json> {
6263
anonymous: boolean | StateDeriver<T>;
6364
}
6465

66+
export type ControllerGetStateAction<
67+
ControllerName extends string,
68+
ControllerState extends Record<string, unknown>,
69+
> = {
70+
type: `${ControllerName}:getState`;
71+
handler: () => ControllerState;
72+
};
73+
74+
export type ControllerStateChangeEvent<
75+
ControllerName extends string,
76+
ControllerState extends Record<string, unknown>,
77+
> = {
78+
type: `${ControllerName}:stateChange`;
79+
payload: [ControllerState, Patch[]];
80+
};
81+
82+
export type ControllerActions<
83+
ControllerName extends string,
84+
ControllerState extends Record<string, unknown>,
85+
> = ControllerGetStateAction<ControllerName, ControllerState>;
86+
87+
export type ControllerEvents<
88+
ControllerName extends string,
89+
ControllerState extends Record<string, unknown>,
90+
> = ControllerStateChangeEvent<ControllerName, ControllerState>;
91+
6592
/**
6693
* Controller class that provides state management, subscriptions, and state metadata
6794
*/
6895
export class BaseController<
6996
N extends string,
7097
S extends Record<string, Json>,
71-
messenger extends RestrictedControllerMessenger<N, any, any, string, string>,
98+
messenger extends RestrictedControllerMessenger<
99+
N,
100+
ActionConstraint | ControllerActions<N, S>,
101+
EventConstraint | ControllerEvents<N, S>,
102+
string,
103+
string
104+
>,
72105
> {
73106
private internalState: S;
74107

@@ -164,7 +197,7 @@ export class BaseController<
164197

165198
this.internalState = nextState;
166199
this.messagingSystem.publish(
167-
`${this.name}:stateChange` as Namespaced<N, any>,
200+
`${this.name}:stateChange`,
168201
nextState,
169202
patches,
170203
);
@@ -183,7 +216,7 @@ export class BaseController<
183216
const nextState = applyPatches(this.internalState, patches);
184217
this.internalState = nextState;
185218
this.messagingSystem.publish(
186-
`${this.name}:stateChange` as Namespaced<N, any>,
219+
`${this.name}:stateChange`,
187220
nextState,
188221
patches,
189222
);
@@ -199,9 +232,7 @@ export class BaseController<
199232
* listeners from being garbage collected.
200233
*/
201234
protected destroy() {
202-
this.messagingSystem.clearEventSubscriptions(
203-
`${this.name}:stateChange` as Namespaced<N, any>,
204-
);
235+
this.messagingSystem.clearEventSubscriptions(`${this.name}:stateChange`);
205236
}
206237
}
207238

@@ -250,20 +281,20 @@ function deriveStateFromMetadata<S extends Record<string, Json>>(
250281
metadata: StateMetadata<S>,
251282
metadataProperty: 'anonymous' | 'persist',
252283
): Record<string, Json> {
253-
return Object.keys(state).reduce((persistedState, key) => {
284+
return (Object.keys(state) as (keyof S)[]).reduce<
285+
Partial<Record<keyof S, Json>>
286+
>((persistedState, key) => {
254287
try {
255-
const stateMetadata = metadata[key as keyof S];
288+
const stateMetadata = metadata[key];
256289
if (!stateMetadata) {
257-
throw new Error(`No metadata found for '${key}'`);
290+
throw new Error(`No metadata found for '${String(key)}'`);
258291
}
259292
const propertyMetadata = stateMetadata[metadataProperty];
260293
const stateProperty = state[key];
261294
if (typeof propertyMetadata === 'function') {
262-
persistedState[key as string] = propertyMetadata(
263-
stateProperty as S[keyof S],
264-
);
295+
persistedState[key] = propertyMetadata(stateProperty);
265296
} else if (propertyMetadata) {
266-
persistedState[key as string] = stateProperty;
297+
persistedState[key] = stateProperty;
267298
}
268299
return persistedState;
269300
} catch (error) {
@@ -274,5 +305,5 @@ function deriveStateFromMetadata<S extends Record<string, Json>>(
274305
});
275306
return persistedState;
276307
}
277-
}, {} as Record<string, Json>);
308+
}, {}) as Record<keyof S, Json>;
278309
}

packages/base-controller/src/ControllerMessenger.ts

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,58 @@
1-
export type ActionHandler<Action, ActionType> = (
1+
export type ActionHandler<
2+
Action extends ActionConstraint,
3+
ActionType = Action['type'],
4+
> = (
25
...args: ExtractActionParameters<Action, ActionType>
36
) => ExtractActionResponse<Action, ActionType>;
4-
export type ExtractActionParameters<Action, T> = Action extends {
7+
8+
export type ExtractActionParameters<
9+
Action extends ActionConstraint,
10+
T = Action['type'],
11+
> = Action extends {
512
type: T;
6-
handler: (...args: infer H) => any;
13+
handler: (...args: infer H) => unknown;
714
}
815
? H
916
: never;
10-
export type ExtractActionResponse<Action, T> = Action extends {
17+
18+
export type ExtractActionResponse<
19+
Action extends ActionConstraint,
20+
T = Action['type'],
21+
> = Action extends {
1122
type: T;
12-
handler: (...args: any) => infer H;
23+
handler: (...args: infer _) => infer H;
1324
}
1425
? H
1526
: never;
1627

17-
export type ExtractEventHandler<Event, T> = Event extends {
28+
export type ExtractEventHandler<
29+
Event extends EventConstraint,
30+
T = Event['type'],
31+
> = Event extends {
1832
type: T;
1933
payload: infer P;
2034
}
2135
? P extends unknown[]
2236
? (...payload: P) => void
2337
: never
2438
: never;
25-
export type ExtractEventPayload<Event, T> = Event extends {
39+
40+
export type ExtractEventPayload<
41+
Event extends EventConstraint,
42+
T = Event['type'],
43+
> = Event extends {
2644
type: T;
2745
payload: infer P;
2846
}
29-
? P
47+
? P extends unknown[]
48+
? P
49+
: never
3050
: never;
3151

3252
export type GenericEventHandler = (...args: unknown[]) => void;
3353

34-
export type SelectorFunction<Args extends unknown[], ReturnValue> = (
35-
...args: Args
54+
export type SelectorFunction<Event extends EventConstraint, ReturnValue> = (
55+
...args: ExtractEventPayload<Event>
3656
) => ReturnValue;
3757
export type SelectorEventHandler<SelectorReturnValue> = (
3858
newValue: SelectorReturnValue,
@@ -41,13 +61,19 @@ export type SelectorEventHandler<SelectorReturnValue> = (
4161

4262
export type ActionConstraint = {
4363
type: string;
44-
handler: (...args: any) => unknown;
64+
handler: ((...args: never) => unknown) | ((...args: never[]) => unknown);
65+
};
66+
export type EventConstraint = {
67+
type: string;
68+
payload: unknown[];
4569
};
46-
export type EventConstraint = { type: string; payload: unknown[] };
4770

48-
type EventSubscriptionMap = Map<
49-
GenericEventHandler | SelectorEventHandler<unknown>,
50-
SelectorFunction<any, unknown> | undefined
71+
type EventSubscriptionMap<
72+
Event extends EventConstraint,
73+
ReturnValue = unknown,
74+
> = Map<
75+
GenericEventHandler | SelectorEventHandler<ReturnValue>,
76+
SelectorFunction<ExtractEventPayload<Event>, ReturnValue> | undefined
5177
>;
5278

5379
/**
@@ -62,6 +88,9 @@ export type Namespaced<Name extends string, T> = T extends `${Name}:${string}`
6288
? T
6389
: never;
6490

91+
export type NamespacedName<Namespace extends string = string> =
92+
`${Namespace}:${string}`;
93+
6594
type NarrowToNamespace<T, Namespace extends string> = T extends {
6695
type: `${Namespace}:${string}`;
6796
}
@@ -151,10 +180,10 @@ export class RestrictedControllerMessenger<
151180
* @param action - The action type. This is a unqiue identifier for this action.
152181
* @param handler - The action handler. This function gets called when the `call` method is
153182
* invoked with the given action type.
154-
* @throws Will throw when a handler has been registered for this action type already.
183+
* @throws Will throw if an action handler that is not in the current namespace is being registered.
155184
* @template T - A type union of Action type strings that are namespaced by N.
156185
*/
157-
registerActionHandler<T extends Namespaced<N, Action['type']>>(
186+
registerActionHandler<T extends NamespacedName<N>>(
158187
action: T,
159188
handler: ActionHandler<Action, T>,
160189
) {
@@ -177,7 +206,7 @@ export class RestrictedControllerMessenger<
177206
* @param action - The action type. This is a unqiue identifier for this action.
178207
* @template T - A type union of Action type strings that are namespaced by N.
179208
*/
180-
unregisterActionHandler<T extends Namespaced<N, Action['type']>>(action: T) {
209+
unregisterActionHandler<T extends NamespacedName<N>>(action: T) {
181210
/* istanbul ignore if */ // Branch unreachable with valid types
182211
if (!action.startsWith(`${this.controllerName}:`)) {
183212
throw new Error(
@@ -202,7 +231,7 @@ export class RestrictedControllerMessenger<
202231
* @template T - A type union of allowed Action type strings.
203232
* @returns The action return value.
204233
*/
205-
call<T extends AllowedAction & string>(
234+
call<T extends AllowedAction & NamespacedName>(
206235
action: T,
207236
...params: ExtractActionParameters<Action, T>
208237
): ExtractActionResponse<Action, T> {
@@ -227,7 +256,7 @@ export class RestrictedControllerMessenger<
227256
* match the type of this payload.
228257
* @template E - A type union of Event type strings that are namespaced by N.
229258
*/
230-
publish<E extends Namespaced<N, Event['type']>>(
259+
publish<E extends NamespacedName<N>>(
231260
event: E,
232261
...payload: ExtractEventPayload<Event, E>
233262
) {
@@ -252,7 +281,7 @@ export class RestrictedControllerMessenger<
252281
* match the type of the payload for this event type.
253282
* @template E - A type union of Event type strings.
254283
*/
255-
subscribe<E extends AllowedEvent & string>(
284+
subscribe<E extends AllowedEvent & NamespacedName>(
256285
eventType: E,
257286
handler: ExtractEventHandler<Event, E>,
258287
): void;
@@ -276,13 +305,13 @@ export class RestrictedControllerMessenger<
276305
* @template E - A type union of Event type strings.
277306
* @template V - The selector return value.
278307
*/
279-
subscribe<E extends AllowedEvent & string, V>(
308+
subscribe<E extends AllowedEvent & NamespacedName, V>(
280309
eventType: E,
281310
handler: SelectorEventHandler<V>,
282311
selector: SelectorFunction<ExtractEventPayload<Event, E>, V>,
283312
): void;
284313

285-
subscribe<E extends AllowedEvent & string, V>(
314+
subscribe<E extends AllowedEvent & NamespacedName, V>(
286315
event: E,
287316
handler: ExtractEventHandler<Event, E>,
288317
selector?: SelectorFunction<ExtractEventPayload<Event, E>, V>,
@@ -312,7 +341,7 @@ export class RestrictedControllerMessenger<
312341
* @throws Will throw when the given event handler is not registered for this event.
313342
* @template T - A type union of allowed Event type strings.
314343
*/
315-
unsubscribe<E extends AllowedEvent & string>(
344+
unsubscribe<E extends AllowedEvent & NamespacedName>(
316345
event: E,
317346
handler: ExtractEventHandler<Event, E>,
318347
) {
@@ -335,7 +364,7 @@ export class RestrictedControllerMessenger<
335364
* @param event - The event type. This is a unique identifier for this event.
336365
* @template E - A type union of Event type strings that are namespaced by N.
337366
*/
338-
clearEventSubscriptions<E extends Namespaced<N, Event['type']>>(event: E) {
367+
clearEventSubscriptions<E extends NamespacedName<N>>(event: E) {
339368
/* istanbul ignore if */ // Branch unreachable with valid types
340369
if (!event.startsWith(`${this.controllerName}:`)) {
341370
throw new Error(
@@ -362,7 +391,10 @@ export class ControllerMessenger<
362391
> {
363392
private readonly actions = new Map<Action['type'], unknown>();
364393

365-
private readonly events = new Map<Event['type'], EventSubscriptionMap>();
394+
private readonly events = new Map<
395+
Event['type'],
396+
EventSubscriptionMap<Event>
397+
>();
366398

367399
/**
368400
* A cache of selector return values for their respective handlers.

packages/rate-limit-controller/src/RateLimitController.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { RestrictedControllerMessenger } from '@metamask/base-controller';
1+
import type {
2+
ActionConstraint,
3+
RestrictedControllerMessenger,
4+
} from '@metamask/base-controller';
25
import { BaseControllerV2 as BaseController } from '@metamask/base-controller';
36
import { rpcErrors } from '@metamask/rpc-errors';
47
import type { Patch } from 'immer';
@@ -10,7 +13,7 @@ import type { Patch } from 'immer';
1013
* @property rateLimitCount - The amount of calls an origin can make in the rate limit time window.
1114
*/
1215
export type RateLimitedApi = {
13-
method: (...args: any[]) => any;
16+
method: ActionConstraint['handler'];
1417
rateLimitTimeout?: number;
1518
rateLimitCount?: number;
1619
};
@@ -121,11 +124,11 @@ export class RateLimitController<
121124

122125
this.messagingSystem.registerActionHandler(
123126
`${name}:call` as const,
124-
((
127+
(
125128
origin: string,
126129
type: keyof RateLimitedApis,
127130
...args: Parameters<RateLimitedApis[keyof RateLimitedApis]['method']>
128-
) => this.call(origin, type, ...args)) as any,
131+
) => this.call(origin, type, ...args),
129132
);
130133
}
131134

@@ -135,7 +138,6 @@ export class RateLimitController<
135138
* @param origin - The requesting origin.
136139
* @param type - The type of API call to make.
137140
* @param args - Arguments for the API call.
138-
* @returns `false` if rate-limited, and `true` otherwise.
139141
*/
140142
async call<ApiType extends keyof RateLimitedApis>(
141143
origin: string,
@@ -155,7 +157,9 @@ export class RateLimitController<
155157
throw new Error('Invalid api type');
156158
}
157159

158-
return implementation(...args);
160+
return implementation(...args) as ReturnType<
161+
RateLimitedApis[ApiType]['method']
162+
>;
159163
}
160164

161165
/**

0 commit comments

Comments
 (0)