diff --git a/package-lock.json b/package-lock.json index 8b41b27c..d3e13acb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "eslint": "9.20.1", "eslint-config-prettier": "10.0.1", "eslint-plugin-prettier": "5.2.3", + "expect-type": "1.1.0", "globals": "15.15.0", "husky": "9.1.7", "jest": "29.7.0", @@ -5162,6 +5163,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", diff --git a/package.json b/package.json index 0480cf90..3c2bc448 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "eslint": "9.20.1", "eslint-config-prettier": "10.0.1", "eslint-plugin-prettier": "5.2.3", + "expect-type": "1.1.0", "globals": "15.15.0", "husky": "9.1.7", "jest": "29.7.0", diff --git a/src/event-bus.ts b/src/event-bus.ts index e42b7d8a..af724463 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'; @@ -39,14 +41,23 @@ export type EventHandlerType = Type< >; @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 { protected eventIdProvider: EventIdProvider; protected readonly subscriptions: Subscription[]; - private _publisher: IEventPublisher; + private _publisher: Publisher; private readonly _logger = new Logger(EventBus.name); constructor( @@ -63,7 +74,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(); } @@ -73,7 +84,7 @@ export class EventBus * Returns the publisher. * Default publisher is `DefaultPubSub` (in memory). */ - get publisher(): IEventPublisher { + get publisher(): Publisher { return this._publisher; } @@ -82,7 +93,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; } @@ -94,7 +105,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. @@ -103,7 +114,7 @@ export class EventBus publish( event: TEvent, asyncContext: AsyncContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -112,7 +123,7 @@ export class EventBus publish( event: TEvent, dispatcherContext: TContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -123,7 +134,7 @@ export class EventBus event: TEvent, dispatcherContext: TContext, asyncContext: AsyncContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -134,7 +145,7 @@ export class EventBus event: TEvent, dispatcherOrAsyncContext?: TContext | AsyncContext, asyncContext?: AsyncContext, - ) { + ): PublishResult { if (!asyncContext && dispatcherOrAsyncContext instanceof AsyncContext) { asyncContext = dispatcherOrAsyncContext; dispatcherOrAsyncContext = undefined; @@ -155,7 +166,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. @@ -164,7 +175,7 @@ export class EventBus publishAll( events: TEvent[], asyncContext: AsyncContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -173,7 +184,7 @@ export class EventBus publishAll( events: TEvent[], dispatcherContext: TContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -184,7 +195,7 @@ export class EventBus events: TEvent[], dispatcherContext: TContext, asyncContext: AsyncContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -195,7 +206,7 @@ export class EventBus events: TEvent[], dispatcherOrAsyncContext?: TContext | AsyncContext, asyncContext?: AsyncContext, - ) { + ): PublishAllResult { if (!asyncContext && dispatcherOrAsyncContext instanceof AsyncContext) { asyncContext = dispatcherOrAsyncContext; dispatcherOrAsyncContext = undefined; @@ -219,7 +230,7 @@ export class EventBus } return (events || []).map((event) => this._publisher.publish(event, dispatcherOrAsyncContext, asyncContext), - ); + ) as PublishAllResult; } bind(handler: InstanceWrapper>, id: string) { @@ -380,7 +391,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.ts b/test/e2e/generics.spec.ts index 2e369e52..8d229b75 100644 --- a/test/e2e/generics.spec.ts +++ b/test/e2e/generics.spec.ts @@ -3,11 +3,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Command, CommandBus, + EventBus, ICommandHandler, + IEvent, + IEventPublisher, + IQueryHandler, Query, QueryBus, } from '../../src'; import { AppModule } from '../src/app.module'; +import { expectTypeOf } from 'expect-type'; describe('Generics', () => { let moduleRef: TestingModule; @@ -32,12 +37,9 @@ describe('Generics', () => { try { await commandBus.execute(command).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -53,10 +55,9 @@ describe('Generics', () => { try { await commandBus.execute(command).then((value) => { - value as string; - value as number; + expectTypeOf(value).toBeAny(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -70,12 +71,9 @@ describe('Generics', () => { try { await commandBus.execute(command).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -91,12 +89,9 @@ describe('Generics', () => { try { await queryBus.execute(query).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -112,10 +107,9 @@ describe('Generics', () => { try { await queryBus.execute(query).then((value) => { - value as string; - value as number; + expectTypeOf(value).toBeAny(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -129,12 +123,9 @@ describe('Generics', () => { try { await queryBus.execute(query).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -150,14 +141,14 @@ describe('Generics', () => { }> {} class ValidHandler implements ICommandHandler { - execute(command: Test): Promise<{ value: string }> { + execute(): Promise<{ value: string }> { throw new Error('Method not implemented.'); } } class InvalidHandler implements ICommandHandler { - // @ts-expect-error - execute(command: Test): Promise<{ value: number }> { + // @ts-expect-error Expected return type is string + execute(): Promise<{ value: number }> { throw new Error('Method not implemented.'); } } @@ -179,12 +170,55 @@ describe('Generics', () => { ); await commandBus.execute(new Test()).then((value) => { - value.value as string; + expectTypeOf(value).toEqualTypeOf<{ value: string }>(); + }); + } catch { + // Do nothing + } finally { + expect(true).toBeTruthy(); + } + }); + }); + + describe('Query handlers', () => { + it('should infer return type', async () => { + class Test 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 string + execute(): Promise<{ value: number }> { + throw new Error('Method not implemented.'); + } + } + + try { + queryBus.bind( + new InstanceWrapper({ + metatype: ValidHandler, + instance: new ValidHandler(), + }), + 'Test', + ); + queryBus.bind( + new InstanceWrapper({ + metatype: InvalidHandler, + instance: new InvalidHandler() as any, + }), + 'Test2', + ); - // @ts-expect-error - value as number; + await queryBus.execute(new Test()).then((value) => { + expectTypeOf(value).toEqualTypeOf<{ value: string }>(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -192,6 +226,132 @@ describe('Generics', () => { }); }); + 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; + + beforeAll(() => { + eventBus = moduleRef.get(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 publish 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; + + beforeAll(() => { + eventBus = moduleRef.get(EventBus); + }); + + it('publish method should return any', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).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; + + beforeAll(() => { + eventBus = moduleRef.get(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; + + beforeAll(() => { + eventBus = moduleRef.get(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).toBeArray(); + expectTypeOf(result).items.toBeString(); + }); + }); + }); + afterAll(async () => { await moduleRef.close(); });