diff --git a/.changeset/flat-hairs-judge.md b/.changeset/flat-hairs-judge.md new file mode 100644 index 0000000..55210e7 --- /dev/null +++ b/.changeset/flat-hairs-judge.md @@ -0,0 +1,6 @@ +--- +'emitten': minor +--- + +- Prefer variadic listener arguments. +- Remove `.disposable()` method. Now returning the `dispose` function from `.on()`. diff --git a/README.md b/README.md index 66704ab..14a6fc8 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,18 @@ Import and start emit’in! ```ts import {Emitten} from 'emitten'; -interface EventMap { - change: string; - count: number; - other: string[]; -} +type EventMap = { + change(value: string): void; + count(value?: number): void; + collect(...values: boolean[]): void; +}; const myEmitter = new Emitten(); -myEmitter.on('change', someFunction); +const dispose = myEmitter.on('change', (value) => {}); myEmitter.emit('change', 'Hello world'); + +dispose(); ``` For more guidance, please take a look at the [`Examples document`](./docs/examples.md). diff --git a/docs/examples.md b/docs/examples.md index 2734d58..c482ee7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -9,83 +9,122 @@ The easiest way to use `Emitten` is to simply instantiate it and begin wiring up For this use case, you most likely want to use `Emitten` instead of `EmittenProtected`. If you instantiate using `EmittenProtected`, you will not be able to call any `protected` members. ```ts -import {Emitten} from 'emitten'; - -// The “event map” that you define. -// This is an object of `eventName` keys, -// and their `callback argument` value. -interface EventMap { - change: string; - count: number; - other: string[]; +// Start by defining your “event map”. +// This is a `Record` type comprised of +// `eventName -> listener function`. + +// It is recommended to use the `type` keyword instead of `interface`! +// There is an important distinction... using `type`, you will: +// 1. Not need to `extend` from `EmittenMap`. +// 2. Automatically receive type-safety for `event` names. + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type EventMap = { + change(value: string): void; + count(value?: number): void; + collect(values: boolean[]): void; + rest(required: string, ...optional: string[]): void; + nothing(): void; +}; + +// If you do prefer using `interface`, just know that: +// 1. You MUST `extend` from `EmittenMap`. +// 2. You will NOT receive type-safety for `event` names. +export interface AltMap extends EmittenMap { + change(value: string): void; + count(value?: number): void; + collect(values: boolean[]): void; + rest(required: string, ...optional: string[]): void; + nothing(): void; } // Instantiate a new instance, passing the `EventMap` // as the generic to `Emitten`. const myEvents = new Emitten(); -// Define your callbacks... -// `value` needs to be “optional”. +// Define your callback functions. -function handleChange(value?: EventMap['change']) { +// If needed, you can grab the `listener` argument +// types by using the TypeScript `Parameters` +// utility and manually selecting the value +// by `key + index`. Example: +type ChangeValue = Parameters[0]; + +function handleChange(value: ChangeValue) { console.log('change', value); } -function handleCount(value?: EventMap['count']) { +function handleCount(value = 0) { console.log('count', value); } -function handleOther(value?: EventMap['other']) { - console.log('other', value); +function handleCollect(values: boolean[]) { + console.log('collect', values); } -// Subscribe to the `change` and `count` events. +// Subscribe to the events you are interested in. myEvents.on('change', handleChange); myEvents.on('count', handleCount); -// Not recommended to pass anonymous functions! -// In order to remove this, you will have to call `.empty()`. -myEvents.on('count', (value) => console.log('2nd count listener', value)); +// Subscribe to the `collect` event only once. The +// subscription will be automatically removed upon `emit`. +myEvents.once('collect', handleCollect); + +// It is not recommended to pass anonymous functions +// like in the example below. In order to remove this, +// you would have to call `.empty()` and clear out +// all of the events from this instance. +myEvents.on('count', (value) => { + console.log('2nd count listener', value); +}); -// Alternatively, you can register a listener using -// `.disposable()`, which will return the corresponding -// `.off()` method to make removal easier. -const registered = myEvents.disposable('count', (value) => - console.log('An anonymous function', value), -); +// However, `.on()` will return the corresponding “dispose” +// for that listener. Simply capture the “disposable” and +// call it when you are ready to remove that listener. +const disposeRest = myEvents.on('rest', (required, ...optional) => { + console.log('An anonymous `rest` function', required, ...optional); +}); -// The listener can now be removed by calling the return value. -registered(); +// Lastly, an example of `listener` without any arguments. +// Since `once` will remove itself after `emit`, it is +// fine to pass anonymous functions. +myEvents.once('nothing', () => { + console.log('Nothing!'); +}); -// Subscribe to the `other` event only once. The -// subscription will be automatically removed upon `emit`. -myEvents.once('other', handleOther); - -// The `value` argument of `emit` is optional -// (can be `undefined`) and therefor is not -// required to `emit` an `event`. -myEvents.emit('change'); -myEvents.emit('count'); -myEvents.emit('other'); +// We can now start emitting events! myEvents.emit('change', 'hello'); -myEvents.emit('count', 2); +myEvents.emit('count'); +myEvents.emit('count', 1); +myEvents.emit('collect', [true, false, true]); +myEvents.emit('rest', '1st string', '2nd string', '3rd string'); +myEvents.emit('nothing'); -// Since the `handleOther` function was registered with `.once()`, -// this 2nd call to `.emit('other')` will not be received by anything. -myEvents.emit('other', ['one', 'two', 'three']); +// Since the `handleCollect` function was registered with `.once()`, +// this 2nd call to `.emit('collect')` will not be received by anything. +myEvents.emit('collect', [true, false, true]); // Attempting to `emit` an `eventName` that does // not exist in the `EventMap`, or passing a `value` // that is not compatible with the defined event’s // value type, will result in a TypeScript error. +myEvents.on('nope', () => {}); +myEvents.off('nope', () => {}); myEvents.emit('nope'); -myEvents.emit('count', 'one'); -// Can manually remove an individual listener. +myEvents.emit('change', '1st string', '2nd string'); +myEvents.emit('count', true); +myEvents.emit('rest'); +myEvents.emit('nothing', 'something'); + +// Can manually remove an individual listener by reference. myEvents.off('change', handleChange); -// Or can completely empty out all events + listeners. +// Or manually call a returned `dispose` function. +disposeRest(); + +// Or you can completely empty out all events + listeners. myEvents.empty(); ``` @@ -94,41 +133,37 @@ myEvents.empty(); Since “derived classes” have access to the `protected` members of their “base class”, you can utilize `EmittenProtected` to both utilize `protected` members while also keeping them `protected` when instantiating your new `class`. ```ts -interface ExtendedEventMap { - custom: string; - other: number; -} +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type ExtendedEventMap = { + custom(value: string): void; + other(value: number): void; +}; class ExtendedEmitten extends EmittenProtected { // If required, you can selectively expose any `protected` members. // Otherwise, if you want all members to be `public`, you can // extend the `Emitten` class instead. - public get activeEvents() { - // Must use the `method`, since `super` cannot - // be called on an accessor. - return super.getActiveEvents(); - } - - public on( - eventName: TKey, - listener: EmittenListener, + public on( + eventName: K, + listener: ExtendedEventMap[K], ) { - super.on(eventName, listener); + const dispose = super.on(eventName, listener); + return dispose; } - public off( - eventName: TKey, - listener: EmittenListener, + public off( + eventName: K, + listener: ExtendedEventMap[K], ) { super.off(eventName, listener); } - public emit( - eventName: TKey, - value?: ExtendedEventMap[TKey], + public emit( + eventName: K, + ...values: Parameters ) { - super.emit(eventName, value); + super.emit(eventName, ...values); } report() { @@ -138,15 +173,15 @@ class ExtendedEmitten extends EmittenProtected { const extended = new ExtendedEmitten(); -// Since we converted both `.on()` and `.emit()` to be `public`, -// we can safely call them on the instance. - -extended.on('custom', (value) => console.log('value', value)); -extended.emit('custom', 'hello'); +extended.on('custom', (value) => { + console.log('value', value); +}); extended.report(); -// However, we did not expose `.empty()`, so we will +extended.emit('custom', 'hello'); + +// We did not expose `.empty()`, so we will // receive a TypeScript error attempting to call this. extended.empty(); ``` @@ -156,32 +191,37 @@ extended.empty(); We can of course create classes that do not extend `Emitten`, and instead create a `private` instance of `Emitten` to perform event actions on. ```ts -function assertValue(value?: string): value is string { - return Boolean(value?.length); -} +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type AnotherMap = { + change(value: string): void; + count(value?: number): void; + names(...values: string[]): void; + diverse(first: string, second: number, ...last: boolean[]): void; +}; class AnotherExample { #id = 'DefaultId'; #counter = 0; - #names: EventMap['other'] = []; + #names: string[] = []; - // Would not be able to call `.emit()` or `.empty()` - // if we had used `Emitten`. - #exampleEvents = new Emitten(); + // Would not be able to call any methods + // if we had used `EmittenProtected`. + #events = new Emitten(); - #handleChange: (value?: EventMap['change']) => void; - #handleCount: (value?: EventMap['count']) => void; - #handleOther: (value?: EventMap['other']) => void; + #handleChange: AnotherMap['change']; + #handleCount: AnotherMap['count']; + #handleNames: AnotherMap['names']; + #handleDiverse: AnotherMap['diverse']; constructor() { this.#handleChange = (value) => { - this.#id = assertValue(value) ? value : this.#id; + this.#id = value.length > 0 ? value : this.#id; console.log('#handleChange', value); }; this.#handleCount = (value) => { if (this.#counter >= 4) { - this.#exampleEvents.off('count', this.#handleCount); + this.#events.off('count', this.#handleCount); } else { this.#counter++; } @@ -189,21 +229,28 @@ class AnotherExample { console.log('#handleCount', value); }; - this.#handleOther = (value) => { - this.#names = value ? [...this.#names, ...value] : this.#names; - console.log('#handleOther', value); + this.#handleNames = (...values) => { + this.#names = [...this.#names, ...values]; + console.log('#handleNames', values); }; - this.#exampleEvents.on('change', this.#handleChange); + this.#handleDiverse = (first, second, ...last) => { + console.log('#handleDiverse > first', first); + console.log('#handleDiverse > second', second); + console.log('#handleDiverse > last', last); + }; - // Registering the same `listener` on the same `event` - // will not create duplicate entries. When `count` is - // emitted, we will see only one call to `#handleCount()`. - this.#exampleEvents.on('count', this.#handleCount); - this.#exampleEvents.on('count', this.#handleCount); - this.#exampleEvents.on('count', this.#handleCount); + this.#events.on('change', this.#handleChange); + this.#events.on('count', this.#handleCount); + this.#events.on('names', this.#handleNames); - this.#exampleEvents.on('other', this.#handleOther); + // Registering the same `listener` on the same `event` + // will not create duplicate entries. When `diverse` is + // emitted, we will see only one call to `#handleDiverse()`. + this.#events.on('diverse', this.#handleDiverse); + this.#events.on('diverse', this.#handleDiverse); + this.#events.on('diverse', this.#handleDiverse); + this.#events.on('diverse', this.#handleDiverse); } get currentId() { @@ -219,24 +266,28 @@ class AnotherExample { } get events() { - return this.#exampleEvents.activeEvents; + return this.#events.activeEvents; } change(value: string) { - this.#exampleEvents.emit('change', value); + this.#events.emit('change', value); } count() { - this.#exampleEvents.emit('count', this.#counter); + this.#events.emit('count', this.#counter); + } + + names(...values: string[]) { + this.#events.emit('names', ...values); } - other(...values: EventMap['other']) { - this.#exampleEvents.emit('other', values); + diverse(first: string, second: number, ...last: boolean[]) { + this.#events.emit('diverse', first, second, ...last); } destroy() { console.log('Removing all listeners from newThinger...'); - this.#exampleEvents.empty(); + this.#events.empty(); } } @@ -255,7 +306,15 @@ document.addEventListener('click', () => { myExample.change('clicked'); myExample.count(); - myExample.other(...otherData); + myExample.names(...otherData); + myExample.diverse( + 'call to diverse', + myExample.currentCount, + true, + false, + true, + false, + ); if (myExample.currentOther.length > otherCollection.length) { console.log('Events BEFORE emptying:', myExample.events); diff --git a/docs/future.md b/docs/future.md index 4e86edb..0ebdc8f 100644 --- a/docs/future.md +++ b/docs/future.md @@ -2,56 +2,24 @@ This document describes some of the follow-up tasks I have in mind. -## Stricter callback argument - -It would be nice to make it so the `value` argument isn't optional. - -```ts -// I might register a listener that will ALWAYS -// pass an argument of a specific type. -function handleChange(data: SomeObject) { - return Object.keys(data); -} - -// I will get a type error here, because `data` -// was not marked as “optional”. -myEvents.on('change', handleChange); - -// Calling `emit` does not require a 2nd argument. -myEvents.emit('change'); -``` - -I would like to find a way to type the `EventMap` generic passed to `Emitten` to that you have more control over how the values are typed. +## Write tests -```ts -// Not sure if the `EmittenListener` type needs to change, -// but it is possible it changes to something like: -type EmittenListener = (...values: T[]) => void; +I have not actually authored any tests yet... but I plan to use `vitest` once I’m ready. -// Then `EventMap` can maybe look something like this: -interface EventMap { - change: (value: string, other?: boolean) => void; - count: (value: number) => void; - other: (value: string[]) => void; -} +## Figure out the right `peerDependencies` -// Then the `emit` method maybe looks something like this: -function emit( - eventName: TKey, - value: Parameters, -) {} -``` +I might need to add `typescript` as a peer dep. -This will require some experimentation. +Also, `@changesets` should be moved to `devDeps`. ## No dynamic delete I got sloppy and used the `delete` keyword... I need to remove the `@typescript-eslint/no-dynamic-delete` override and filter that `object` properly. -## Write tests +## Revisit loose equality checks -I have not actually authored any tests yet... but I plan to use `vitest` once I’m ready. +There are some `null` checks in `EmittenProtected` I wan't to more thoroughly check. -## Figure out the right `peerDependencies` +## Reconsider some TSConfig/ESLint -I might need to add `typescript` as a peer dep. +I already know that I want to remove the `@typescript-eslint/no-dynamic-delete` fron `.eslintrc`. Additionally, I might want to disable `@typescript-eslint/strict-boolean-expressions`. diff --git a/src/Emitten.ts b/src/Emitten.ts index 198f1d1..158ec07 100644 --- a/src/Emitten.ts +++ b/src/Emitten.ts @@ -1,45 +1,26 @@ import {EmittenProtected} from './EmittenProtected'; -import type {EmittenListener} from './types'; +import type {EmittenMap} from './types'; -export class Emitten extends EmittenProtected { +export class Emitten extends EmittenProtected { public get activeEvents() { - return super.getActiveEvents(); + return this.getActiveEvents(); } - public off( - eventName: TKey, - listener: EmittenListener, - ) { + public off(eventName: K, listener: T[K]) { super.off(eventName, listener); } - public on( - eventName: TKey, - listener: EmittenListener, - ) { - super.on(eventName, listener); + public on(eventName: K, listener: T[K]) { + const dispose = super.on(eventName, listener); + return dispose; } - public once( - eventName: TKey, - listener: EmittenListener, - ) { + public once(eventName: K, listener: T[K]) { super.once(eventName, listener); } - public disposable( - eventName: TKey, - listener: EmittenListener, - ) { - const result = super.disposable(eventName, listener); - return result; - } - - public emit( - eventName: TKey, - value?: TEventMap[TKey], - ) { - super.emit(eventName, value); + public emit(eventName: K, ...values: Parameters) { + super.emit(eventName, ...values); } public empty() { diff --git a/src/EmittenProtected.ts b/src/EmittenProtected.ts index cb88804..77588c3 100644 --- a/src/EmittenProtected.ts +++ b/src/EmittenProtected.ts @@ -1,8 +1,8 @@ -import type {EmittenListener, EmittenLibrary} from './types'; +import type {EmittenMap, EmittenLibraryPartial} from './types'; -export class EmittenProtected { - #multiLibrary: EmittenLibrary = {}; - #singleLibrary: EmittenLibrary = {}; +export class EmittenProtected { + #multiLibrary: EmittenLibraryPartial = {}; + #singleLibrary: EmittenLibraryPartial = {}; protected get activeEvents() { // This redundant getter + method are required @@ -15,14 +15,12 @@ export class EmittenProtected { const singleKeys = Object.keys(this.#singleLibrary); const dedupedKeys = new Set([...multiKeys, ...singleKeys]); + const result: Array = [...dedupedKeys]; - return [...dedupedKeys]; + return result; } - protected off( - eventName: TKey, - listener: EmittenListener, - ) { + protected off(eventName: K, listener: T[K]) { const multiSet = this.#multiLibrary[eventName]; const singleSet = this.#singleLibrary[eventName]; @@ -37,21 +35,19 @@ export class EmittenProtected { } } - protected on( - eventName: TKey, - listener: EmittenListener, - ) { + protected on(eventName: K, listener: T[K]) { if (this.#multiLibrary[eventName] == null) { this.#multiLibrary[eventName] = new Set(); } this.#multiLibrary[eventName]?.add(listener); + + return () => { + this.off(eventName, listener); + }; } - protected once( - eventName: TKey, - listener: EmittenListener, - ) { + protected once(eventName: K, listener: T[K]) { if (this.#singleLibrary[eventName] == null) { this.#singleLibrary[eventName] = new Set(); } @@ -59,30 +55,16 @@ export class EmittenProtected { this.#singleLibrary[eventName]?.add(listener); } - protected disposable( - eventName: TKey, - listener: EmittenListener, - ) { - this.on(eventName, listener); - - return () => { - this.off(eventName, listener); - }; - } - - protected emit( - eventName: TKey, - value?: TEventMap[TKey], - ) { + protected emit(eventName: K, ...values: Parameters) { const multiSet = this.#multiLibrary[eventName]; const singleSet = this.#singleLibrary[eventName]; multiSet?.forEach((listener) => { - listener(value); + listener(...values); }); singleSet?.forEach((listener) => { - listener(value); + listener(...values); }); delete this.#singleLibrary[eventName]; @@ -93,7 +75,7 @@ export class EmittenProtected { this.#every(this.#singleLibrary); } - #every = (library: EmittenLibrary) => { + #every = (library: EmittenLibraryPartial) => { for (const eventName in library) { if (Object.hasOwn(library, eventName)) { library[eventName]?.forEach((listener) => { diff --git a/src/index.ts b/src/index.ts index 3dcdd12..a5b1c8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ export {Emitten} from './Emitten'; export {EmittenProtected} from './EmittenProtected'; -export type {EmittenListener, EmittenLibrary} from './types'; +export type { + EmittenKey, + EmittenListener, + EmittenMap, + EmittenLibrary, + EmittenLibraryPartial, +} from './types'; diff --git a/src/types.ts b/src/types.ts index 525a399..e71344e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,9 @@ -export type EmittenListener = (value?: TValue) => void; +export type EmittenKey = string | symbol; +export type EmittenListener = ( + ...values: V +) => void; -export type EmittenLibrary = { - [event in keyof TEventMap]?: Set>; -}; +export type EmittenMap = Record; + +export type EmittenLibrary = Record>; +export type EmittenLibraryPartial = Partial>;