From 0ae874d4cf2136c6d14d2f798852f9757dfaa241 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 30 Mar 2026 19:19:42 +0200 Subject: [PATCH 01/12] feat(messenger): add `delegateAll` with exhaustive type checking `delegate` accepts partial action/event lists without compile-time enforcement that all required items are present. `delegateAll` uses a branded intersection type to produce a clear TS error showing exactly which actions or events are missing. --- packages/messenger/src/Messenger.test.ts | 127 +++++++++++++++++++++++ packages/messenger/src/Messenger.ts | 99 ++++++++++++++++++ packages/messenger/src/index.ts | 1 + 3 files changed, 227 insertions(+) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index df0547199ec..cc796c0b61b 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1625,6 +1625,133 @@ describe('Messenger', () => { }); }); + describe('delegateAll', () => { + it('delegates all listed actions and events', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + type ChildOwnAction = { + type: 'Child:doStuff'; + handler: () => void; + }; + type SourceEvent = { + type: 'Source:stateChange'; + payload: [{ value: number }]; + }; + + const sourceMessenger = new Messenger< + 'Source', + SourceAction | ChildOwnAction, + SourceEvent + >({ namespace: 'Source' }); + + const childMessenger = new Messenger< + 'Child', + SourceAction | ChildOwnAction, + SourceEvent + >({ namespace: 'Child' }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 42); + + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: ['Source:stateChange'], + }); + + // Child can now call the delegated action + expect(childMessenger.call('Source:getValue')).toBe(42); + + // Child can now subscribe to the delegated event + const subscriber = jest.fn(); + childMessenger.subscribe('Source:stateChange', subscriber); + sourceMessenger.publish('Source:stateChange', { value: 1 }); + expect(subscriber).toHaveBeenCalledWith({ value: 1 }); + }); + + it('delegates actions with an empty events array', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + + const sourceMessenger = new Messenger<'Source', SourceAction, never>({ + namespace: 'Source', + }); + const childMessenger = new Messenger<'Child', SourceAction, never>({ + namespace: 'Child', + }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 99); + + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: [], + }); + + expect(childMessenger.call('Source:getValue')).toBe(99); + }); + + it('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + type ChildOwnAction = { + type: 'Child:doStuff'; + handler: () => void; + }; + + const sourceMessenger = new Messenger< + 'Source', + SourceAction | ChildOwnAction, + never + >({ namespace: 'Source' }); + + const childMessenger = new Messenger< + 'Child', + SourceAction | ChildOwnAction, + never + >({ namespace: 'Child' }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 42); + + // This should compile without listing 'Child:doStuff' — it belongs + // to the child's own namespace and will be registered by the child. + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: [], + }); + + expect(childMessenger.call('Source:getValue')).toBe(42); + }); + + // The following test verifies compile-time behaviour. Uncomment to see + // the TypeScript error when an action is missing from the list. + // + // it('produces a type error when an action is missing', () => { + // type ActionA = { type: 'A:getValue'; handler: () => number }; + // type ActionB = { type: 'B:getName'; handler: () => string }; + // + // const source = new Messenger<'Source', ActionA | ActionB, never>({ + // namespace: 'Source', + // }); + // const child = new Messenger<'Child', ActionA | ActionB, never>({ + // namespace: 'Child', + // }); + // + // // @ts-expect-error — 'B:getName' is missing from the actions list + // source.delegateAll({ + // messenger: child, + // actions: ['A:getValue'], + // events: [], + // }); + // }); + }); + describe('revoke', () => { it('throws when attempting to revoke from parent', () => { type ExampleEvent = { diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index e592da27586..96ffac6047c 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -94,6 +94,46 @@ export type MessengerEvents< ? Event : never; +/** + * Extract the namespace from a Messenger type. + * + * @template Subject - The messenger type to extract from. + */ +export type MessengerNamespace< + Subject extends Messenger, +> = + Subject extends Messenger + ? N + : never; + +/** + * Validate that all members of a union are present in a tuple. + * + * When all required members are present, evaluates to the input tuple unchanged. + * When members are missing, evaluates to a branded intersection type that + * produces a clear compile error showing exactly which items are missing via + * the `__missing` property. + * + * @template Required - The union of all required string types. + * @template Provided - The readonly tuple of provided string types. + * @example + * ```typescript + * // OK — all required items present + * type T1 = RequireExhaustive<'A' | 'B', readonly ['A', 'B']>; + * // => readonly ['A', 'B'] + * + * // Error — 'C' is missing + * type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>; + * // => readonly ['A', 'B'] & { __missing: 'C' } + * ``` + */ +type RequireExhaustive< + Required extends string, + Provided extends readonly string[], +> = [Exclude] extends [never] + ? Provided + : Provided & { __missing: Exclude }; + /** * Messenger namespace checks can be disabled by using this as the `namespace` constructor * parameter, and using `MockAnyNamespace` as the Namespace type parameter. @@ -855,6 +895,65 @@ export class Messenger< } } + /** + * Delegate all external actions and events to another messenger, with + * compile-time exhaustiveness checking. + * + * Unlike {@link delegate}, which accepts a partial list of actions/events, + * this method requires that **every** action and event the delegatee needs + * from outside its own namespace is included. If any are missing, TypeScript + * produces a type error showing the missing items. + * + * Use this when a single source messenger provides all external + * actions/events for a child messenger (the common pattern in controller + * initialisation). + * + * @param args - Arguments. + * @param args.actions - The action types to delegate. Must include every + * action type defined on the delegatee that is **not** under its own + * namespace. + * @param args.events - The event types to delegate. Must include every event + * type defined on the delegatee that is **not** under its own namespace. + * @param args.messenger - The messenger to delegate to. + * @template Delegatee - The messenger the actions/events are delegated to. + * @template DelegatedActions - A const tuple of delegated action type + * strings. + * @template DelegatedEvents - A const tuple of delegated event type strings. + */ + delegateAll< + Delegatee extends Messenger, + const DelegatedActions extends readonly (MessengerActions & + Action)['type'][], + const DelegatedEvents extends readonly (MessengerEvents & + Event)['type'][], + >({ + actions, + events, + messenger, + }: { + messenger: Delegatee; + actions: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + (MessengerActions & Action)['type'] + >, + DelegatedActions + >; + events: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + (MessengerEvents & Event)['type'] + >, + DelegatedEvents + >; + }): void { + this.delegate({ + actions: actions as (MessengerActions & Action)['type'][], + events: events as (MessengerEvents & Event)['type'][], + messenger, + }); + } + /** * Revoke delegated actions and/or events from another messenger. * diff --git a/packages/messenger/src/index.ts b/packages/messenger/src/index.ts index 6fb7aaefeda..f2f28fffb06 100644 --- a/packages/messenger/src/index.ts +++ b/packages/messenger/src/index.ts @@ -10,6 +10,7 @@ export type { EventConstraint, MessengerActions, MessengerEvents, + MessengerNamespace, MockAnyNamespace, NamespacedBy, NotNamespacedBy, From cc5b50125118aff8e2e703bd1a60afe8ebb778e2 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 30 Mar 2026 19:22:57 +0200 Subject: [PATCH 02/12] docs: add changelog entry for `delegateAll` --- packages/messenger/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index 9b7ca7f9179..653cbad51c0 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `delegateAll` method for exhaustive delegation with compile-time checking ([#8338](https://github.com/MetaMask/core/pull/8338)) + - Unlike `delegate`, this method requires all external actions and events to be listed, producing a TypeScript error showing exactly which items are missing. +- Add `MessengerNamespace` utility type to extract the namespace from a Messenger type ([#8338](https://github.com/MetaMask/core/pull/8338)) + ### Deprecated - Deprecate `generate-action-types` CLI tool and `messenger-generate-action-types` binary ([#8378](https://github.com/MetaMask/core/pull/8378)) From 5517720b27bcc2185bc8b4587206dca723d7dc03 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 30 Mar 2026 19:30:13 +0200 Subject: [PATCH 03/12] fix: address lint errors in delegateAll --- packages/messenger/src/Messenger.test.ts | 21 --------------------- packages/messenger/src/Messenger.ts | 6 +++--- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index cc796c0b61b..d045d9c6a7d 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1729,27 +1729,6 @@ describe('Messenger', () => { expect(childMessenger.call('Source:getValue')).toBe(42); }); - // The following test verifies compile-time behaviour. Uncomment to see - // the TypeScript error when an action is missing from the list. - // - // it('produces a type error when an action is missing', () => { - // type ActionA = { type: 'A:getValue'; handler: () => number }; - // type ActionB = { type: 'B:getName'; handler: () => string }; - // - // const source = new Messenger<'Source', ActionA | ActionB, never>({ - // namespace: 'Source', - // }); - // const child = new Messenger<'Child', ActionA | ActionB, never>({ - // namespace: 'Child', - // }); - // - // // @ts-expect-error — 'B:getName' is missing from the actions list - // source.delegateAll({ - // messenger: child, - // actions: ['A:getValue'], - // events: [], - // }); - // }); }); describe('revoke', () => { diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index 96ffac6047c..f9eda99db55 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -112,7 +112,7 @@ export type MessengerNamespace< * When all required members are present, evaluates to the input tuple unchanged. * When members are missing, evaluates to a branded intersection type that * produces a clear compile error showing exactly which items are missing via - * the `__missing` property. + * the `missingDelegations` property. * * @template Required - The union of all required string types. * @template Provided - The readonly tuple of provided string types. @@ -124,7 +124,7 @@ export type MessengerNamespace< * * // Error — 'C' is missing * type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>; - * // => readonly ['A', 'B'] & { __missing: 'C' } + * // => readonly ['A', 'B'] & { missingDelegations: 'C' } * ``` */ type RequireExhaustive< @@ -132,7 +132,7 @@ type RequireExhaustive< Provided extends readonly string[], > = [Exclude] extends [never] ? Provided - : Provided & { __missing: Exclude }; + : Provided & { missingDelegations: Exclude }; /** * Messenger namespace checks can be disabled by using this as the `namespace` constructor From e17d99a11f767bd5746eb2875434e36c83fd0462 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 30 Mar 2026 19:35:05 +0200 Subject: [PATCH 04/12] fix: use @ts-expect-error for missing action type test --- packages/messenger/src/Messenger.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index d045d9c6a7d..3584a985ed6 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1729,6 +1729,24 @@ describe('Messenger', () => { expect(childMessenger.call('Source:getValue')).toBe(42); }); + it('produces a type error when an action is missing', () => { + type ActionA = { type: 'A:getValue'; handler: () => number }; + type ActionB = { type: 'B:getName'; handler: () => string }; + + const source = new Messenger<'Source', ActionA | ActionB, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + // @ts-expect-error — 'B:getName' is missing from the actions list + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }); + }); }); describe('revoke', () => { From 0bc2ac9c37f92dc877259a998c6785306bac5cb1 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 30 Mar 2026 19:43:27 +0200 Subject: [PATCH 05/12] fix: add assertion to satisfy jest/expect-expect --- packages/messenger/src/Messenger.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 3584a985ed6..ba682a7dbbd 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1740,12 +1740,14 @@ describe('Messenger', () => { namespace: 'Child', }); - // @ts-expect-error — 'B:getName' is missing from the actions list - source.delegateAll({ - messenger: child, - actions: ['A:getValue'], - events: [], - }); + expect(() => + // @ts-expect-error — 'B:getName' is missing from the actions list + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).not.toThrow(); }); }); From 6ca6fbc76c4241c787709b6cfa9fc7399b26a5f9 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 16:34:55 +0200 Subject: [PATCH 06/12] test: silence :stateChange lint in messenger --- packages/messenger/src/Messenger.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index a4c755de014..fea5ee0388c 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1971,6 +1971,7 @@ describe('Messenger', () => { // Child can now subscribe to the delegated event const subscriber = jest.fn(); + // eslint-disable-next-line no-restricted-syntax childMessenger.subscribe('Source:stateChange', subscriber); sourceMessenger.publish('Source:stateChange', { value: 1 }); expect(subscriber).toHaveBeenCalledWith({ value: 1 }); From 08fc1b35cb4f2a099b54b52158b422aaeeca166f Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Sat, 25 Jul 2026 17:34:02 +0200 Subject: [PATCH 07/12] fix(messenger): address delegateAll review feedback Align delegateAll type params with delegate/revoke, drop casts, and rename the missing-delegations brand for clearer type errors. --- packages/messenger/src/Messenger.test.ts | 2 +- packages/messenger/src/Messenger.ts | 30 ++++++++++-------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index fea5ee0388c..498726b6f07 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -2048,9 +2048,9 @@ describe('Messenger', () => { }); expect(() => - // @ts-expect-error — 'B:getName' is missing from the actions list source.delegateAll({ messenger: child, + // @ts-expect-error — 'B:getName' is missing from the actions list actions: ['A:getValue'], events: [], }), diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index f0bee36e64a..40f3ba84151 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -112,7 +112,7 @@ export type MessengerNamespace< * When all required members are present, evaluates to the input tuple unchanged. * When members are missing, evaluates to a branded intersection type that * produces a clear compile error showing exactly which items are missing via - * the `missingDelegations` property. + * the `__MISSING_DELEGATIONS__` property. * * @template Required - The union of all required string types. * @template Provided - The readonly tuple of provided string types. @@ -124,7 +124,7 @@ export type MessengerNamespace< * * // Error — 'C' is missing * type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>; - * // => readonly ['A', 'B'] & { missingDelegations: 'C' } + * // => readonly ['A', 'B'] & { __MISSING_DELEGATIONS__: 'C' } * ``` */ type RequireExhaustive< @@ -132,7 +132,8 @@ type RequireExhaustive< Provided extends readonly string[], > = [Exclude] extends [never] ? Provided - : Provided & { missingDelegations: Exclude }; + : // eslint-disable-next-line @typescript-eslint/naming-convention + Provided & { __MISSING_DELEGATIONS__: Exclude }; /** * Messenger namespace checks can be disabled by using this as the `namespace` constructor @@ -1190,16 +1191,15 @@ export class Messenger< * type defined on the delegatee that is **not** under its own namespace. * @param args.messenger - The messenger to delegate to. * @template Delegatee - The messenger the actions/events are delegated to. - * @template DelegatedActions - A const tuple of delegated action type - * strings. - * @template DelegatedEvents - A const tuple of delegated event type strings. + * @template DelegatedActions - An array of delegated action type strings. + * @template DelegatedEvents - An array of delegated event type strings. */ delegateAll< Delegatee extends Messenger, - const DelegatedActions extends readonly (MessengerActions & - Action)['type'][], - const DelegatedEvents extends readonly (MessengerEvents & - Event)['type'][], + DelegatedActions extends (MessengerActions['type'] & + Action['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + Event['type'])[], >({ actions, events, @@ -1209,23 +1209,19 @@ export class Messenger< actions: RequireExhaustive< NotNamespacedBy< MessengerNamespace, - (MessengerActions & Action)['type'] + MessengerActions['type'] & Action['type'] >, DelegatedActions >; events: RequireExhaustive< NotNamespacedBy< MessengerNamespace, - (MessengerEvents & Event)['type'] + MessengerEvents['type'] & Event['type'] >, DelegatedEvents >; }): void { - this.delegate({ - actions: actions as (MessengerActions & Action)['type'][], - events: events as (MessengerEvents & Event)['type'][], - messenger, - }); + this.delegate({ actions, events, messenger }); } /** From e2fca54f6c82c2f52000261db8c4a46d683f0aac Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Sat, 25 Jul 2026 17:48:02 +0200 Subject: [PATCH 08/12] test(messenger): add tstyche coverage for delegateAll Move exhaustiveness checks out of Jest into compile-time tests so missing delegations are verified by the type checker. --- packages/messenger/jest.config.js | 3 + packages/messenger/package.json | 5 +- .../src/Messenger.delegateAll.tst.ts | 68 +++++++++++++++++++ packages/messenger/src/Messenger.test.ts | 55 --------------- packages/messenger/tsconfig.build.json | 3 +- yarn.lock | 15 ++++ 6 files changed, 92 insertions(+), 57 deletions(-) create mode 100644 packages/messenger/src/Messenger.delegateAll.tst.ts diff --git a/packages/messenger/jest.config.js b/packages/messenger/jest.config.js index c108bad51d7..9ad52b2640b 100644 --- a/packages/messenger/jest.config.js +++ b/packages/messenger/jest.config.js @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // Exclude TSTyche type test files from coverage collection + collectCoverageFrom: ['./src/**/*.ts', '!**/*.tst.ts'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/messenger/package.json b/packages/messenger/package.json index d7feadb0eb4..7390f8f093d 100644 --- a/packages/messenger/package.json +++ b/packages/messenger/package.json @@ -47,7 +47,9 @@ "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test": "yarn test:unit && yarn test:types", + "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:types": "tstyche", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" @@ -63,6 +65,7 @@ "immer": "^9.0.6", "jest": "^30.4.2", "ts-jest": "^29.4.11", + "tstyche": "5.0.2", "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/packages/messenger/src/Messenger.delegateAll.tst.ts b/packages/messenger/src/Messenger.delegateAll.tst.ts new file mode 100644 index 00000000000..096e9c981f6 --- /dev/null +++ b/packages/messenger/src/Messenger.delegateAll.tst.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'tstyche'; + +import { Messenger } from './Messenger.js'; + +describe('Messenger.delegateAll', () => { + type ActionA = { type: 'A:getValue'; handler: () => number }; + type ActionB = { type: 'B:getName'; handler: () => string }; + type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void }; + type SourceEvent = { + type: 'Source:stateChange'; + payload: [{ value: number }]; + }; + + test('accepts a complete list of external actions and events', () => { + const source = new Messenger< + 'Source', + ActionA | ActionB | ChildOwnAction, + SourceEvent + >({ namespace: 'Source' }); + const child = new Messenger< + 'Child', + ActionA | ActionB | ChildOwnAction, + SourceEvent + >({ namespace: 'Child' }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue', 'B:getName'], + events: ['Source:stateChange'], + }), + ).type.not.toRaiseError(); + }); + + test('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { + const source = new Messenger<'Source', ActionA | ChildOwnAction, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ChildOwnAction, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.not.toRaiseError(); + }); + + test('raises a type error when an external action is missing', () => { + const source = new Messenger<'Source', ActionA | ActionB, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); +}); diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 498726b6f07..6fcde055d2c 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -2001,61 +2001,6 @@ describe('Messenger', () => { expect(childMessenger.call('Source:getValue')).toBe(99); }); - it('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { - type SourceAction = { - type: 'Source:getValue'; - handler: () => number; - }; - type ChildOwnAction = { - type: 'Child:doStuff'; - handler: () => void; - }; - - const sourceMessenger = new Messenger< - 'Source', - SourceAction | ChildOwnAction, - never - >({ namespace: 'Source' }); - - const childMessenger = new Messenger< - 'Child', - SourceAction | ChildOwnAction, - never - >({ namespace: 'Child' }); - - sourceMessenger.registerActionHandler('Source:getValue', () => 42); - - // This should compile without listing 'Child:doStuff' — it belongs - // to the child's own namespace and will be registered by the child. - sourceMessenger.delegateAll({ - messenger: childMessenger, - actions: ['Source:getValue'], - events: [], - }); - - expect(childMessenger.call('Source:getValue')).toBe(42); - }); - - it('produces a type error when an action is missing', () => { - type ActionA = { type: 'A:getValue'; handler: () => number }; - type ActionB = { type: 'B:getName'; handler: () => string }; - - const source = new Messenger<'Source', ActionA | ActionB, never>({ - namespace: 'Source', - }); - const child = new Messenger<'Child', ActionA | ActionB, never>({ - namespace: 'Child', - }); - - expect(() => - source.delegateAll({ - messenger: child, - // @ts-expect-error — 'B:getName' is missing from the actions list - actions: ['A:getValue'], - events: [], - }), - ).not.toThrow(); - }); }); describe('revoke', () => { diff --git a/packages/messenger/tsconfig.build.json b/packages/messenger/tsconfig.build.json index 02a0eea03fe..cfccd58d5bf 100644 --- a/packages/messenger/tsconfig.build.json +++ b/packages/messenger/tsconfig.build.json @@ -6,5 +6,6 @@ "rootDir": "./src" }, "references": [], - "include": ["../../types", "./src"] + "include": ["../../types", "./src"], + "exclude": ["**/*.tst.ts"] } diff --git a/yarn.lock b/yarn.lock index 997429db97c..5aecf0977b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7561,6 +7561,7 @@ __metadata: immer: "npm:^9.0.6" jest: "npm:^30.4.2" ts-jest: "npm:^29.4.11" + tstyche: "npm:5.0.2" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -24988,6 +24989,20 @@ __metadata: languageName: node linkType: hard +"tstyche@npm:5.0.2": + version: 5.0.2 + resolution: "tstyche@npm:5.0.2" + peerDependencies: + typescript: ">=5.0" + peerDependenciesMeta: + typescript: + optional: true + bin: + tstyche: ./build/bin.js + checksum: 10/bbc11c2f7c5c1cbf4f848a9af1eef947cf9b2643491a59fb7dbaa903bb15bba620dad24904c3db6279f206a23599d97d47f9d9e00473c54f6bb9375a7dc26178 + languageName: node + linkType: hard + "tsx@npm:^4.20.5": version: 4.20.5 resolution: "tsx@npm:4.20.5" From a3bb887663b65778e8e153c84a3a35e601906483 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Sat, 25 Jul 2026 17:53:26 +0200 Subject: [PATCH 09/12] fix(messenger): restore test excludes and satisfy constraints Keep Jest test files out of the build when excluding TSTyche files, format misc lint failures, and allow messenger's combined test script. --- packages/messenger/package.json | 4 ++-- packages/messenger/src/Messenger.test.ts | 1 - packages/messenger/tsconfig.build.json | 2 +- yarn.config.cjs | 9 ++++++++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/messenger/package.json b/packages/messenger/package.json index 7390f8f093d..303fe99cb27 100644 --- a/packages/messenger/package.json +++ b/packages/messenger/package.json @@ -48,9 +48,9 @@ "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "yarn test:unit && yarn test:types", - "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", - "test:types": "tstyche", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:types": "tstyche", + "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 6fcde055d2c..3658cecaf9b 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -2000,7 +2000,6 @@ describe('Messenger', () => { expect(childMessenger.call('Source:getValue')).toBe(99); }); - }); describe('revoke', () => { diff --git a/packages/messenger/tsconfig.build.json b/packages/messenger/tsconfig.build.json index cfccd58d5bf..12ff215ad3b 100644 --- a/packages/messenger/tsconfig.build.json +++ b/packages/messenger/tsconfig.build.json @@ -7,5 +7,5 @@ }, "references": [], "include": ["../../types", "./src"], - "exclude": ["**/*.tst.ts"] + "exclude": ["**/*.test.ts", "**/*.tst.ts"] } diff --git a/yarn.config.cjs b/yarn.config.cjs index c3bf8a25688..724034ff43a 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -180,7 +180,14 @@ module.exports = defineConfig({ // @metamask/wallet-cli prepends a better-sqlite3 prebuild fetch to its // "test" script because the native addon isn't built during // `yarn install` (Yarn runs with `enableScripts: false`). - if (workspace.ident !== '@metamask/wallet-cli') { + // @metamask/messenger also runs TSTyche compile-time tests after Jest. + if (workspace.ident === '@metamask/messenger') { + expectWorkspaceField( + workspace, + 'scripts.test', + 'yarn test:unit && yarn test:types', + ); + } else if (workspace.ident !== '@metamask/wallet-cli') { expectWorkspaceField( workspace, 'scripts.test', From 7c68fdfb917f5b074832895268cdcf52d4cef2be Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 27 Jul 2026 13:40:09 +0200 Subject: [PATCH 10/12] fix(messenger): fail loudly when source lacks required delegations Require every external delegatee action/event in the exhaustiveness check, even when the source type omits them, and cover missing events plus unsupported source deps in tstyche. --- .../src/Messenger.delegateAll.tst.ts | 48 +++++++++++++++++-- packages/messenger/src/Messenger.ts | 9 +++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/messenger/src/Messenger.delegateAll.tst.ts b/packages/messenger/src/Messenger.delegateAll.tst.ts index 096e9c981f6..9418b758160 100644 --- a/packages/messenger/src/Messenger.delegateAll.tst.ts +++ b/packages/messenger/src/Messenger.delegateAll.tst.ts @@ -6,28 +6,32 @@ describe('Messenger.delegateAll', () => { type ActionA = { type: 'A:getValue'; handler: () => number }; type ActionB = { type: 'B:getName'; handler: () => string }; type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void }; - type SourceEvent = { - type: 'Source:stateChange'; + type EventA = { + type: 'A:stateChange'; payload: [{ value: number }]; }; + type EventB = { + type: 'B:nameChange'; + payload: [{ name: string }]; + }; test('accepts a complete list of external actions and events', () => { const source = new Messenger< 'Source', ActionA | ActionB | ChildOwnAction, - SourceEvent + EventA | EventB >({ namespace: 'Source' }); const child = new Messenger< 'Child', ActionA | ActionB | ChildOwnAction, - SourceEvent + EventA | EventB >({ namespace: 'Child' }); expect( source.delegateAll({ messenger: child, actions: ['A:getValue', 'B:getName'], - events: ['Source:stateChange'], + events: ['A:stateChange', 'B:nameChange'], }), ).type.not.toRaiseError(); }); @@ -65,4 +69,38 @@ describe('Messenger.delegateAll', () => { }), ).type.toRaiseError(); }); + + test('raises a type error when an external event is missing', () => { + const source = new Messenger<'Source', never, EventA | EventB>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', never, EventA | EventB>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: [], + events: ['A:stateChange'], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when the source cannot provide a required external action', () => { + const source = new Messenger<'Source', ActionA, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); }); diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index 40f3ba84151..ba63f9ab405 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -1179,6 +1179,11 @@ export class Messenger< * from outside its own namespace is included. If any are missing, TypeScript * produces a type error showing the missing items. * + * The source messenger's action/event types must include every required + * external item. Items the source cannot provide still appear in the + * missing set, so incomplete source typing fails loudly instead of being + * silently skipped. + * * Use this when a single source messenger provides all external * actions/events for a child messenger (the common pattern in controller * initialisation). @@ -1209,14 +1214,14 @@ export class Messenger< actions: RequireExhaustive< NotNamespacedBy< MessengerNamespace, - MessengerActions['type'] & Action['type'] + MessengerActions['type'] >, DelegatedActions >; events: RequireExhaustive< NotNamespacedBy< MessengerNamespace, - MessengerEvents['type'] & Event['type'] + MessengerEvents['type'] >, DelegatedEvents >; From cb9e3aab78919d9b60a3b51324c113bd55b46409 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 27 Jul 2026 13:44:57 +0200 Subject: [PATCH 11/12] test(messenger): consolidate type tests into Messenger.tst.ts Mirror the Jest layout with one Messenger type test file and nested describes instead of a per-method file. --- .../src/Messenger.delegateAll.tst.ts | 106 ----------------- packages/messenger/src/Messenger.tst.ts | 108 ++++++++++++++++++ 2 files changed, 108 insertions(+), 106 deletions(-) delete mode 100644 packages/messenger/src/Messenger.delegateAll.tst.ts create mode 100644 packages/messenger/src/Messenger.tst.ts diff --git a/packages/messenger/src/Messenger.delegateAll.tst.ts b/packages/messenger/src/Messenger.delegateAll.tst.ts deleted file mode 100644 index 9418b758160..00000000000 --- a/packages/messenger/src/Messenger.delegateAll.tst.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from 'tstyche'; - -import { Messenger } from './Messenger.js'; - -describe('Messenger.delegateAll', () => { - type ActionA = { type: 'A:getValue'; handler: () => number }; - type ActionB = { type: 'B:getName'; handler: () => string }; - type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void }; - type EventA = { - type: 'A:stateChange'; - payload: [{ value: number }]; - }; - type EventB = { - type: 'B:nameChange'; - payload: [{ name: string }]; - }; - - test('accepts a complete list of external actions and events', () => { - const source = new Messenger< - 'Source', - ActionA | ActionB | ChildOwnAction, - EventA | EventB - >({ namespace: 'Source' }); - const child = new Messenger< - 'Child', - ActionA | ActionB | ChildOwnAction, - EventA | EventB - >({ namespace: 'Child' }); - - expect( - source.delegateAll({ - messenger: child, - actions: ['A:getValue', 'B:getName'], - events: ['A:stateChange', 'B:nameChange'], - }), - ).type.not.toRaiseError(); - }); - - test('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { - const source = new Messenger<'Source', ActionA | ChildOwnAction, never>({ - namespace: 'Source', - }); - const child = new Messenger<'Child', ActionA | ChildOwnAction, never>({ - namespace: 'Child', - }); - - expect( - source.delegateAll({ - messenger: child, - actions: ['A:getValue'], - events: [], - }), - ).type.not.toRaiseError(); - }); - - test('raises a type error when an external action is missing', () => { - const source = new Messenger<'Source', ActionA | ActionB, never>({ - namespace: 'Source', - }); - const child = new Messenger<'Child', ActionA | ActionB, never>({ - namespace: 'Child', - }); - - expect( - source.delegateAll({ - messenger: child, - actions: ['A:getValue'], - events: [], - }), - ).type.toRaiseError(); - }); - - test('raises a type error when an external event is missing', () => { - const source = new Messenger<'Source', never, EventA | EventB>({ - namespace: 'Source', - }); - const child = new Messenger<'Child', never, EventA | EventB>({ - namespace: 'Child', - }); - - expect( - source.delegateAll({ - messenger: child, - actions: [], - events: ['A:stateChange'], - }), - ).type.toRaiseError(); - }); - - test('raises a type error when the source cannot provide a required external action', () => { - const source = new Messenger<'Source', ActionA, never>({ - namespace: 'Source', - }); - const child = new Messenger<'Child', ActionA | ActionB, never>({ - namespace: 'Child', - }); - - expect( - source.delegateAll({ - messenger: child, - actions: ['A:getValue'], - events: [], - }), - ).type.toRaiseError(); - }); -}); diff --git a/packages/messenger/src/Messenger.tst.ts b/packages/messenger/src/Messenger.tst.ts new file mode 100644 index 00000000000..757abe21991 --- /dev/null +++ b/packages/messenger/src/Messenger.tst.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'tstyche'; + +import { Messenger } from './Messenger.js'; + +describe('Messenger', () => { + describe('delegateAll', () => { + type ActionA = { type: 'A:getValue'; handler: () => number }; + type ActionB = { type: 'B:getName'; handler: () => string }; + type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void }; + type EventA = { + type: 'A:stateChange'; + payload: [{ value: number }]; + }; + type EventB = { + type: 'B:nameChange'; + payload: [{ name: string }]; + }; + + test('accepts a complete list of external actions and events', () => { + const source = new Messenger< + 'Source', + ActionA | ActionB | ChildOwnAction, + EventA | EventB + >({ namespace: 'Source' }); + const child = new Messenger< + 'Child', + ActionA | ActionB | ChildOwnAction, + EventA | EventB + >({ namespace: 'Child' }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue', 'B:getName'], + events: ['A:stateChange', 'B:nameChange'], + }), + ).type.not.toRaiseError(); + }); + + test('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { + const source = new Messenger<'Source', ActionA | ChildOwnAction, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ChildOwnAction, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.not.toRaiseError(); + }); + + test('raises a type error when an external action is missing', () => { + const source = new Messenger<'Source', ActionA | ActionB, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when an external event is missing', () => { + const source = new Messenger<'Source', never, EventA | EventB>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', never, EventA | EventB>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: [], + events: ['A:stateChange'], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when the source cannot provide a required external action', () => { + const source = new Messenger<'Source', ActionA, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); + }); +}); From 22ed38bbbe3670bf3a6a923640e1d1432453c88a Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 09:29:51 +0100 Subject: [PATCH 12/12] fix(messenger): exclude TSTyche tests from lint:tsc Intentional type errors in Messenger.tst.ts fail project typecheck; keep them under yarn test:types only. --- packages/messenger/tsconfig.lint.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/messenger/tsconfig.lint.json b/packages/messenger/tsconfig.lint.json index 378ed1dce55..672c3683956 100644 --- a/packages/messenger/tsconfig.lint.json +++ b/packages/messenger/tsconfig.lint.json @@ -3,5 +3,6 @@ "compilerOptions": { "outDir": "./.tsc-lint-cache", "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" - } + }, + "exclude": ["**/*.tst.ts"] }