diff --git a/src/event-bus.ts b/src/event-bus.ts index 5c65dad1..cf551c5e 100644 --- a/src/event-bus.ts +++ b/src/event-bus.ts @@ -28,6 +28,8 @@ import { IEventHandler, IEventPublisher, ISaga, + PublisherPublishAllResult, + PublisherPublishResult, UnhandledExceptionInfo, } from './interfaces'; import { AsyncContext } from './scopes'; @@ -47,7 +49,16 @@ export type EventOperator = UnaryFunction< * @publicApi */ @Injectable() -export class EventBus +export class EventBus< + EventBase extends IEvent = IEvent, + Publisher extends IEventPublisher< + EventBase, + PublishResult, + PublishAllResult + > = IEventPublisher, + PublishResult = PublisherPublishResult, + PublishAllResult = PublisherPublishAllResult, + > extends ObservableBus implements IEventBus, OnModuleDestroy { @@ -56,7 +67,7 @@ export class EventBus public readonly eventOperators: EventOperator[] = []; - private _publisher: IEventPublisher; + private _publisher: Publisher; private readonly _logger = new Logger(EventBus.name); constructor( @@ -73,7 +84,7 @@ export class EventBus this.options?.eventIdProvider ?? defaultEventIdProvider; if (this.options?.eventPublisher) { - this._publisher = this.options.eventPublisher; + this._publisher = this.options.eventPublisher as Publisher; } else { this.useDefaultPublisher(); } @@ -83,7 +94,7 @@ export class EventBus * Returns the publisher. * Default publisher is `DefaultPubSub` (in memory). */ - get publisher(): IEventPublisher { + get publisher(): Publisher { return this._publisher; } @@ -92,7 +103,7 @@ export class EventBus * Default publisher is `DefaultPubSub` (in memory). * @param _publisher The publisher to set. */ - set publisher(_publisher: IEventPublisher) { + set publisher(_publisher: Publisher) { this._publisher = _publisher; } @@ -104,7 +115,7 @@ export class EventBus * Publishes an event. * @param event The event to publish. */ - publish(event: TEvent): any; + publish(event: TEvent): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -113,7 +124,7 @@ export class EventBus publish( event: TEvent, asyncContext: AsyncContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -122,7 +133,7 @@ export class EventBus publish( event: TEvent, dispatcherContext: TContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -133,7 +144,7 @@ export class EventBus event: TEvent, dispatcherContext: TContext, asyncContext: AsyncContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -144,7 +155,7 @@ export class EventBus event: TEvent, dispatcherOrAsyncContext?: TContext | AsyncContext, asyncContext?: AsyncContext, - ) { + ): PublishResult { if (!asyncContext && dispatcherOrAsyncContext instanceof AsyncContext) { asyncContext = dispatcherOrAsyncContext; dispatcherOrAsyncContext = undefined; @@ -165,7 +176,7 @@ export class EventBus * Publishes multiple events. * @param events The events to publish. */ - publishAll(events: TEvent[]): any; + publishAll(events: TEvent[]): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -174,7 +185,7 @@ export class EventBus publishAll( events: TEvent[], asyncContext: AsyncContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -183,7 +194,7 @@ export class EventBus publishAll( events: TEvent[], dispatcherContext: TContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -194,7 +205,7 @@ export class EventBus events: TEvent[], dispatcherContext: TContext, asyncContext: AsyncContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -205,7 +216,7 @@ export class EventBus events: TEvent[], dispatcherOrAsyncContext?: TContext | AsyncContext, asyncContext?: AsyncContext, - ) { + ): PublishAllResult { if (!asyncContext && dispatcherOrAsyncContext instanceof AsyncContext) { asyncContext = dispatcherOrAsyncContext; dispatcherOrAsyncContext = undefined; @@ -229,7 +240,7 @@ export class EventBus } return (events || []).map((event) => this._publisher.publish(event, dispatcherOrAsyncContext, asyncContext), - ); + ) as PublishAllResult; } bind(handler: InstanceWrapper>, id: string) { @@ -391,7 +402,9 @@ export class EventBus } private useDefaultPublisher() { - this._publisher = new DefaultPubSub(this.subject$); + this._publisher = new DefaultPubSub( + this.subject$, + ) as unknown as Publisher; } private mapToUnhandledErrorInfo( diff --git a/src/interfaces/events/event-publisher.interface.ts b/src/interfaces/events/event-publisher.interface.ts index 45b0d6dd..1f05b88b 100644 --- a/src/interfaces/events/event-publisher.interface.ts +++ b/src/interfaces/events/event-publisher.interface.ts @@ -1,7 +1,11 @@ import { AsyncContext } from '../../scopes'; import { IEvent } from './event.interface'; -export interface IEventPublisher { +export interface IEventPublisher< + EventBase extends IEvent = IEvent, + PublishResult = any, + PublishAllResult = any, +> { /** * Publishes an event. * @param event The event to publish. @@ -12,7 +16,7 @@ export interface IEventPublisher { event: TEvent, dispatcherContext?: unknown, asyncContext?: AsyncContext, - ): any; + ): PublishResult; /** * Publishes multiple events. @@ -24,5 +28,15 @@ export interface IEventPublisher { events: TEvent[], dispatcherContext?: unknown, asyncContext?: AsyncContext, - ): any; + ): PublishAllResult; } + +export type PublisherPublishResult

= + P extends IEventPublisher ? PublishResult : never; + +export type PublisherPublishAllResult

= + P extends IEventPublisher + ? P['publishAll'] extends Function + ? PublishAllResult + : PublishResult[] + : never; diff --git a/test/e2e/generics.spec-d.ts b/test/e2e/generics.spec-d.ts new file mode 100644 index 00000000..40a01e62 --- /dev/null +++ b/test/e2e/generics.spec-d.ts @@ -0,0 +1,265 @@ +import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; +import { + Command, + CommandBus, + EventBus, + ICommandHandler, + IEvent, + IEventPublisher, + IQueryHandler, + Query, + QueryBus, +} from '../../src'; + +describe('Generics', () => { + let commandBus!: CommandBus; + let queryBus!: QueryBus; + + describe('Commands', () => { + describe('when "Command" utility class is used', () => { + it('should infer return type', () => { + const command = new Command(); + + expectTypeOf(commandBus.execute(command)).resolves.toBeString(); + }); + }); + + describe('when any other class is used', () => { + it('should fallback to any return type', () => { + class MyCommand {} + + const command = new MyCommand(); + + expectTypeOf(commandBus.execute(command)).resolves.toBeAny(); + }); + + it('should use the 2nd generic parameter as return type', () => { + class MyCommand {} + + const command = new MyCommand(); + + expectTypeOf( + commandBus.execute(command), + ).resolves.toBeString(); + }); + }); + }); + + describe('Queries', () => { + describe('when "Query" utility class is used', () => { + it('should infer return type', () => { + const query = new Query(); + + expectTypeOf(queryBus.execute(query)).resolves.toBeString(); + }); + }); + + describe('when any other class is used', () => { + it('should fallback to any return type', () => { + class MyQuery {} + + const query = new MyQuery(); + + expectTypeOf(queryBus.execute(query)).resolves.toBeAny(); + }); + + it('should use the 2nd generic parameter as return type', () => { + class MyQuery {} + + const query = new MyQuery(); + + expectTypeOf( + queryBus.execute(query), + ).resolves.toBeString(); + }); + }); + }); + + describe('Command handlers', () => { + it('should infer return type', () => { + class MyCommand extends Command<{ + value: string; + }> {} + + class ValidHandler implements ICommandHandler { + execute(): Promise<{ value: string }> { + throw new Error('Method not implemented.'); + } + } + + class InvalidHandler implements ICommandHandler { + // @ts-expect-error Expected return type is { value: string } + execute(): Promise<{ value: number }> { + throw new Error('Method not implemented.'); + } + } + + commandBus.bind( + new InstanceWrapper({ + metatype: ValidHandler, + instance: new ValidHandler(), + }), + 'Test', + ); + commandBus.bind( + new InstanceWrapper({ + metatype: InvalidHandler, + instance: new InvalidHandler() as any, + }), + 'Test2', + ); + + expectTypeOf(commandBus.execute(new MyCommand())).resolves.toEqualTypeOf<{ + value: string; + }>(); + }); + }); + + describe('Query handlers', () => { + it('should infer return type', () => { + class MyQuery extends Query<{ + value: string; + }> {} + + class ValidHandler implements IQueryHandler { + execute(): Promise<{ value: string }> { + throw new Error('Method not implemented.'); + } + } + + class InvalidHandler implements IQueryHandler { + // @ts-expect-error Expected return type is { value: string } + execute(): Promise<{ value: number }> { + throw new Error('Method not implemented.'); + } + } + + queryBus.bind( + new InstanceWrapper({ + metatype: ValidHandler, + instance: new ValidHandler(), + }), + 'Test', + ); + queryBus.bind( + new InstanceWrapper({ + metatype: InvalidHandler, + instance: new InvalidHandler() as any, + }), + 'Test2', + ); + + expectTypeOf(queryBus.execute(new MyQuery())).resolves.toEqualTypeOf<{ + value: string; + }>(); + }); + }); + + describe('EventBus', () => { + describe('when custom event type is passed', () => { + class CustomEvent { + constructor(readonly foo: string) {} + } + + class ExtendedCustomEvent extends CustomEvent { + constructor( + foo: string, + readonly bar: string, + ) { + super(foo); + } + } + + let eventBus!: EventBus; + + it('publish method should forbid other objects than CustomEvent', () => { + // @ts-expect-error publish requires a CustomEvent + eventBus.publish({ id: 'test' }); + }); + + it('publish method should accept CustomEvent', () => { + eventBus.publish(new CustomEvent('foo')); + }); + + it('publish method should accept CustomEvent extensions', () => { + eventBus.publish(new ExtendedCustomEvent('foo', 'bar')); + }); + + it('publishAll method should forbid other objects than CustomEvent', () => { + // @ts-expect-error publishAll requires a CustomEvent + eventBus.publishAll([{ id: 'test' }]); + }); + + it('publishAll method should accept CustomEvent', () => { + eventBus.publishAll([new CustomEvent('foo')]); + }); + + it('publishAll method should accept CustomEvent extensions', () => { + eventBus.publishAll([new ExtendedCustomEvent('foo', 'bar')]); + }); + }); + + describe('when default event publisher is used', () => { + let eventBus!: EventBus; + + it('publish method should return any', () => { + expectTypeOf(eventBus.publish({ id: 'test' })).toBeAny(); + }); + + it('publishAll method should return array of any', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeArray(); + expectTypeOf(result).items.toBeAny(); + }); + }); + + describe('when a custom event publisher is used', () => { + class Publisher implements IEventPublisher { + publish() { + return 'any string here'; + } + publishAll() { + return true; + } + } + + let eventBus!: EventBus; + + it('publish method should return string', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeString(); + }); + + it('publishAll method should return boolean', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeBoolean(); + }); + }); + + describe('when a custom event publisher is used, but does not implement publishAll', () => { + class Publisher implements IEventPublisher { + publish() { + return 'any string here'; + } + } + + let eventBus!: EventBus; + + it('publish method should return string', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeString(); + }); + + it('publishAll method should fall back to an array of publish results', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeArray(); + expectTypeOf(result).items.toBeString(); + }); + }); + }); +}); diff --git a/test/e2e/generics.spec.ts b/test/e2e/generics.spec.ts deleted file mode 100644 index 2e369e52..00000000 --- a/test/e2e/generics.spec.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; -import { Test, TestingModule } from '@nestjs/testing'; -import { - Command, - CommandBus, - ICommandHandler, - Query, - QueryBus, -} from '../../src'; -import { AppModule } from '../src/app.module'; - -describe('Generics', () => { - let moduleRef: TestingModule; - let commandBus: CommandBus; - let queryBus: QueryBus; - - beforeAll(async () => { - moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - await moduleRef.init(); - - commandBus = moduleRef.get(CommandBus); - queryBus = moduleRef.get(QueryBus); - }); - - describe('Commands', () => { - describe('when "Command" utility class is used', () => { - it('should infer return type', async () => { - const command = new Command(); - - try { - await commandBus.execute(command).then((value) => { - value as string; - - // @ts-expect-error - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - describe('when any other class is used', () => { - it('should fallback to any return type', async () => { - class MyCommand {} - - const command = new MyCommand(); - - try { - await commandBus.execute(command).then((value) => { - value as string; - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - - it('should use the 2nd generic parameter as return type', async () => { - class MyCommand {} - - const command = new MyCommand(); - - try { - await commandBus.execute(command).then((value) => { - value as string; - - // @ts-expect-error - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - }); - - describe('Queries', () => { - describe('when "Query" utility class is used', () => { - it('should infer return type', async () => { - const query = new Query(); - - try { - await queryBus.execute(query).then((value) => { - value as string; - - // @ts-expect-error - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - describe('when any other class is used', () => { - it('should fallback to any return type', async () => { - class MyQuery {} - - const query = new MyQuery(); - - try { - await queryBus.execute(query).then((value) => { - value as string; - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - - it('should use the 2nd generic parameter as return type', async () => { - class MyQuery {} - - const query = new MyQuery(); - - try { - await queryBus.execute(query).then((value) => { - value as string; - - // @ts-expect-error - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - }); - - describe('Command handlers', () => { - it('should infer return type', async () => { - class Test extends Command<{ - value: string; - }> {} - - class ValidHandler implements ICommandHandler { - execute(command: Test): Promise<{ value: string }> { - throw new Error('Method not implemented.'); - } - } - - class InvalidHandler implements ICommandHandler { - // @ts-expect-error - execute(command: Test): Promise<{ value: number }> { - throw new Error('Method not implemented.'); - } - } - - try { - commandBus.bind( - new InstanceWrapper({ - metatype: ValidHandler, - instance: new ValidHandler(), - }), - 'Test', - ); - commandBus.bind( - new InstanceWrapper({ - metatype: InvalidHandler, - instance: new InvalidHandler() as any, - }), - 'Test2', - ); - - await commandBus.execute(new Test()).then((value) => { - value.value as string; - - // @ts-expect-error - value as number; - }); - } catch (err) { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - afterAll(async () => { - await moduleRef.close(); - }); -}); diff --git a/test/tsconfig.json b/test/tsconfig.json index c776541e..951aa555 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "rootDir": "../" + "rootDir": "../", + // The root config uses "node10" for the published CommonJS build, which + // cannot resolve the ESM-only type exports of Vitest 4 / Vite 8. + "moduleResolution": "bundler" }, "include": [ "src/**/*.ts", diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index ef2048eb..04f4cd4a 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -9,5 +9,9 @@ export default defineConfig({ environment: 'node', include: ['test/e2e/**/*.spec.ts'], fileParallelism: false, + // Type-checks the *.spec-d.ts files. Without an explicit tsconfig this + // falls back to the root one, whose "node10" resolution cannot read the + // ESM-only type exports of Vitest 4 / Vite 8. + typecheck: { enabled: true, tsconfig: 'test/tsconfig.json' }, }, });