Repository: sindresorhus/emittery
Pull request #96 (2022-04-20 23:51:48)

Allow event names to be numbers
Hi 👋

I'd like to use emittery to model the packets sent between a client and server. All packets are identified by a "type", which is a number. Therefore I was hoping to use emittery in the following way:

```ts
enum ServerPacketType {
  LOGIN_FAILED = 3,
  CHAT = 17,
  SYNC_CLOCK = 30
}

interface ServerPacketEvents {
  [ServerPacketType.LOGIN_FAILED]: [reason: string];
  [ServerPacketType.CHAT]: [from: number, message: string];
  [ServerPacketType.SYNC_CLOCK]: [time: number];
}

const emitter = new Emittery<ServerPacketEvents>();
emitter.on(ServerPacketType.LOGIN_FAILED, ([reason]) => {
  console.log('Login failed!', reason);
});
```

This actually compiles fine, there's no type error. But at runtime an error is thrown here:

https://github.com/sindresorhus/emittery/blob/3cf4a0a2c9344d0ab0d1f1f23957e3bbde98f617/index.js#L14-L18

Maybe I'm missing something crucial here, but I don't see any harm in supporting numbers for event names? So I prepared this PR which does just that. Let me know what you think.

Thanks.

lukehorvat: Also, FYI the issue linked in this comment here appears to have been shipped, so I can update this line to `Record<string | symbol | number, any>` if you like. 

https://github.com/sindresorhus/emittery/blob/3cf4a0a2c9344d0ab0d1f1f23957e3bbde98f617/index.d.ts#L167
sindresorhus: > Also, FYI the issue linked in this comment here appears to have been shipped, so I can update this line to Record<string | symbol | number, any> if you like.

👍 I think TS actually has built-in object key type you could use.
sindresorhus: Yeah, I'm ok with adding this. But can you add a short note that it's generally preferred to use symbols than numbers?
lukehorvat: > But can you add a short note that it's generally preferred to use symbols than numbers?

There was already a note in the readme about the benefit of using symbols. I added the word "preferred" to it but wasn't sure what else I could add. Is it what you had in mind?

> 👍 I think TS actually has built-in object key type you could use.

Nice! I pushed a commit replacing `string | symbol | number` with [`PropertyKey`](https://github.com/microsoft/TypeScript/blob/7abdb9e7efdf14ebbb94597bdd279b66a7968a99/src/lib/es5.d.ts#L87).

However now that I redefined `EventData`'s key type as a `PropertyKey`, these tests fail:

https://github.com/sindresorhus/emittery/blob/3cf4a0a2c9344d0ab0d1f1f23957e3bbde98f617/index.test-d.ts#L105-L110

I guess now that `EventData` accepts symbol keys, `listenerRemoved` and `listenerAdded` can be emitted without error. I'm not really sure how to solve this at a type level. I tried doing something like `EventData<Exclude<PropertyKey, typeof listenerAdded>, any>`, but that doesn't work. Maybe we have to make it a runtime check?
sindresorhus: > Maybe we have to make it a runtime check?

👍 
sindresorhus: > There was already a note in the readme about the benefit of using symbols. I added the word "preferred" to it but wasn't sure what else I could add. Is it what you had in mind?

👍 
lukehorvat: I pushed a commit to make it a runtime check. 👍 I added a second (boolean) param to `assertEventName` that controls whether or not the event name can be `listenerRemoved` and `listenerAdded`. All internal calls to `emit`/`emitSerial` specify the param as `true` to avoid any error being thrown.

Not really sure if this is the best way though. It means `emit`/`emitSerial` now has an undocumented third param that userland could exploit if they are curious enough... 

One idea I had to prevent any exploit was to change the param from a boolean to an internal (not publicly exported) symbol:

```js
const metaEventsAllowed = Symbol('metaEventsAllowed');

function assertEventName(eventName, allowMetaEvents) {
	if (typeof eventName !== 'string' && typeof eventName !== 'symbol' && typeof eventName !== 'number') {
		throw new TypeError('`eventName` must be a string, symbol, or number');
	}

	if (isListenerSymbol(eventName) && allowMetaEvents !== metaEventsAllowed) {
		throw new TypeError('`eventName` cannot be meta event `listenerAdded` or `listenerRemoved`');
	}
}
```

This way userland would never be able to bypass the error. I haven't worked with symbols much so I'm not sure whether this is a good pattern or not. wdyt?
sindresorhus: > This way userland would never be able to bypass the error. I haven't worked with symbols much so I'm not sure whether this is a good pattern or not. wdyt?

👍 That's a good use for symbols.
lukehorvat: Modified the runtime check to use a private symbol. 👌

I also added another commit renaming `isListenerSymbol` to `isMetaEvent` because I think it makes it a bit clearer when reading the code to understand what a "meta" event is. But I can revert if you disagree.

Patch:
diff --git a/index.d.ts b/index.d.ts
index 880ab86..de19515 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,11 +1,11 @@
 /* eslint-disable no-redeclare */
 
 /**
-Emittery accepts strings and symbols as event names.
+Emittery accepts strings, symbols, and numbers as event names.
 
-Symbol event names can be used to avoid name collisions when your classes are extended, especially for internal events.
+Symbol event names are preferred given that they can be used to avoid name collisions when your classes are extended, especially for internal events.
 */
-type EventName = string | symbol;
+type EventName = PropertyKey;
 
 // Helper type for turning the passed `EventData` type map into a list of string keys that don't require data alongside the event name when emitting. Uses the same trick that `Omit` does internally to filter keys by building a map of keys to keys we want to keep, and then accessing all the keys to return just the list of keys we want to keep.
 type DatalessEventNames<EventData> = {
@@ -90,7 +90,7 @@ interface DebugOptions<EventData> {
 	(type, debugName, eventName, eventData) => {
 		eventData = JSON.stringify(eventData);
 
-		if (typeof eventName === 'symbol') {
+		if (typeof eventName === 'symbol' || typeof eventName === 'number') {
 			eventName = eventName.toString();
 		}
 
@@ -142,7 +142,7 @@ Emittery is a strictly typed, fully async EventEmitter implementation. Event lis
 import Emittery = require('emittery');
 
 const emitter = new Emittery<
-	// Pass `{[eventName: <string | symbol>]: undefined | <eventArg>}` as the first type argument for events that pass data to their listeners.
+	// Pass `{[eventName: <string | symbol | number>]: undefined | <eventArg>}` as the first type argument for events that pass data to their listeners.
 	// A value of `undefined` in this map means the event listeners should expect no data, and a type other than `undefined` means the listeners will receive one argument of that type.
 	{
 		open: string,
@@ -164,7 +164,7 @@ emitter.emit('other');
 ```
 */
 declare class Emittery<
-	EventData = Record<string, any>, // When https://github.com/microsoft/TypeScript/issues/1863 ships, we can switch this to have an index signature including Symbols. If you want to use symbol keys right now, you need to pass an interface with those symbol keys explicitly listed.
+	EventData = Record<EventName, any>,
 	AllEventData = EventData & _OmnipresentEventData,
 	DatalessEvents = DatalessEventNames<EventData>
 > {
diff --git a/index.js b/index.js
index 597594f..09c2fcc 100644
--- a/index.js
+++ b/index.js
@@ -6,14 +6,22 @@ const producersMap = new WeakMap();
 const anyProducer = Symbol('anyProducer');
 const resolvedPromise = Promise.resolve();
 
+// Define symbols for "meta" events.
 const listenerAdded = Symbol('listenerAdded');
 const listenerRemoved = Symbol('listenerRemoved');
 
+// Define a symbol that allows internal code to emit meta events, but prevents userland from doing so.
+const metaEventsAllowed = Symbol('metaEventsAllowed');
+
 let isGlobalDebugEnabled = false;
 
-function assertEventName(eventName) {
-	if (typeof eventName !== 'string' && typeof eventName !== 'symbol') {
-		throw new TypeError('eventName must be a string or a symbol');
+function assertEventName(eventName, allowMetaEvents) {
+	if (typeof eventName !== 'string' && typeof eventName !== 'symbol' && typeof eventName !== 'number') {
+		throw new TypeError('`eventName` must be a string, symbol, or number');
+	}
+
+	if (isMetaEvent(eventName) && allowMetaEvents !== metaEventsAllowed) {
+		throw new TypeError('`eventName` cannot be meta event `listenerAdded` or `listenerRemoved`');
 	}
 }
 
@@ -33,7 +41,7 @@ function getListeners(instance, eventName) {
 }
 
 function getEventProducers(instance, eventName) {
-	const key = typeof eventName === 'string' || typeof eventName === 'symbol' ? eventName : anyProducer;
+	const key = typeof eventName === 'string' || typeof eventName === 'symbol' || typeof eventName === 'number' ? eventName : anyProducer;
 	const producers = producersMap.get(instance);
 	if (!producers.has(key)) {
 		producers.set(key, new Set());
@@ -147,7 +155,7 @@ function defaultMethodNamesOrAssert(methodNames) {
 	return methodNames;
 }
 
-const isListenerSymbol = symbol => symbol === listenerAdded || symbol === listenerRemoved;
+const isMetaEvent = eventName => eventName === listenerAdded || eventName === listenerRemoved;
 
 class Emittery {
 	static mixin(emitteryPropertyName, methodNames) {
@@ -223,7 +231,7 @@ class Emittery {
 					eventData = `Object with the following keys failed to stringify: ${Object.keys(eventData).join(',')}`;
 				}
 
-				if (typeof eventName === 'symbol') {
+				if (typeof eventName === 'symbol' || typeof eventName === 'number') {
 					eventName = eventName.toString();
 				}
 
@@ -245,13 +253,13 @@ class Emittery {
 
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
-			assertEventName(eventName);
+			assertEventName(eventName, metaEventsAllowed);
 			getListeners(this, eventName).add(listener);
 
 			this.logIfDebugEnabled('subscribe', eventName, undefined);
 
-			if (!isListenerSymbol(eventName)) {
-				this.emit(listenerAdded, {eventName, listener});
+			if (!isMetaEvent(eventName)) {
+				this.emit(listenerAdded, {eventName, listener}, metaEventsAllowed);
 			}
 		}
 
@@ -263,13 +271,13 @@ class Emittery {
 
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
-			assertEventName(eventName);
+			assertEventName(eventName, metaEventsAllowed);
 			getListeners(this, eventName).delete(listener);
 
 			this.logIfDebugEnabled('unsubscribe', eventName, undefined);
 
-			if (!isListenerSymbol(eventName)) {
-				this.emit(listenerRemoved, {eventName, listener});
+			if (!isMetaEvent(eventName)) {
+				this.emit(listenerRemoved, {eventName, listener}, metaEventsAllowed);
 			}
 		}
 	}
@@ -286,14 +294,14 @@ class Emittery {
 	events(eventNames) {
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
-			assertEventName(eventName);
+			assertEventName(eventName, metaEventsAllowed);
 		}
 
 		return iterator(this, eventNames);
 	}
 
-	async emit(eventName, eventData) {
-		assertEventName(eventName);
+	async emit(eventName, eventData, allowMetaEvents) {
+		assertEventName(eventName, allowMetaEvents);
 
 		this.logIfDebugEnabled('emit', eventName, eventData);
 
@@ -302,7 +310,7 @@ class Emittery {
 		const listeners = getListeners(this, eventName);
 		const anyListeners = anyMap.get(this);
 		const staticListeners = [...listeners];
-		const staticAnyListeners = isListenerSymbol(eventName) ? [] : [...anyListeners];
+		const staticAnyListeners = isMetaEvent(eventName) ? [] : [...anyListeners];
 
 		await resolvedPromise;
 		await Promise.all([
@@ -319,8 +327,8 @@ class Emittery {
 		]);
 	}
 
-	async emitSerial(eventName, eventData) {
-		assertEventName(eventName);
+	async emitSerial(eventName, eventData, allowMetaEvents) {
+		assertEventName(eventName, allowMetaEvents);
 
 		this.logIfDebugEnabled('emitSerial', eventName, eventData);
 
@@ -351,7 +359,7 @@ class Emittery {
 		this.logIfDebugEnabled('subscribeAny', undefined, undefined);
 
 		anyMap.get(this).add(listener);
-		this.emit(listenerAdded, {listener});
+		this.emit(listenerAdded, {listener}, metaEventsAllowed);
 		return this.offAny.bind(this, listener);
 	}
 
@@ -364,7 +372,7 @@ class Emittery {
 
 		this.logIfDebugEnabled('unsubscribeAny', undefined, undefined);
 
-		this.emit(listenerRemoved, {listener});
+		this.emit(listenerRemoved, {listener}, metaEventsAllowed);
 		anyMap.get(this).delete(listener);
 	}
 
@@ -374,7 +382,7 @@ class Emittery {
 		for (const eventName of eventNames) {
 			this.logIfDebugEnabled('clear', eventName, undefined);
 
-			if (typeof eventName === 'string' || typeof eventName === 'symbol') {
+			if (typeof eventName === 'string' || typeof eventName === 'symbol' || typeof eventName === 'number') {
 				getListeners(this, eventName).clear();
 
 				const producers = getEventProducers(this, eventName);
@@ -414,7 +422,7 @@ class Emittery {
 			}
 
 			if (typeof eventName !== 'undefined') {
-				assertEventName(eventName);
+				assertEventName(eventName, metaEventsAllowed);
 			}
 
 			count += anyMap.get(this).size;
diff --git a/readme.md b/readme.md
index b3ddd16..eba9168 100644
--- a/readme.md
+++ b/readme.md
@@ -40,9 +40,9 @@ emitter.emit(myUnicorn, '🦋');  // Will trigger printing 'Unicorns love 🦋'
 
 ### eventName
 
-Emittery accepts strings and symbols as event names.
+Emittery accepts strings, symbols, and numbers as event names.
 
-Symbol event names can be used to avoid name collisions when your classes are extended, especially for internal events.
+Symbol event names are preferred given that they can be used to avoid name collisions when your classes are extended, especially for internal events.
 
 ### isDebugEnabled
 
@@ -160,7 +160,7 @@ Default:
 		eventData = JSON.stringify(eventData);
 	}
 
-	if (typeof eventName === 'symbol') {
+	if (typeof eventName === 'symbol' || typeof eventName === 'number') {
 		eventName = eventName.toString();
 	}
 
@@ -222,7 +222,7 @@ emitter.emit('🐶', '🍖'); // log => '🍖'
 
 ##### Custom subscribable events
 
-Emittery exports some symbols which represent custom events that can be passed to `Emitter.on` and similar methods.
+Emittery exports some symbols which represent "meta" events that can be passed to `Emitter.on` and similar methods.
 
 - `Emittery.listenerAdded` - Fires when an event listener was added.
 - `Emittery.listenerRemoved` - Fires when an event listener was removed.


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 0386bbc..8fb41a2 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -21,11 +21,11 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 	ee.on('anEvent', async data => Promise.resolve());
 	ee.on(['anEvent', 'anotherEvent'], async data => undefined);
 	ee.on(Emittery.listenerAdded, ({eventName, listener}) => {
-		expectType<string | symbol | undefined>(eventName);
+		expectType<PropertyKey | undefined>(eventName);
 		expectType<AnyListener>(listener);
 	});
 	ee.on(Emittery.listenerRemoved, ({eventName, listener}) => {
-		expectType<string | symbol | undefined>(eventName);
+		expectType<PropertyKey | undefined>(eventName);
 		expectType<AnyListener>(listener);
 	});
 }
@@ -47,11 +47,11 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 	const test = async () => {
 		await ee.once('anEvent');
 		await ee.once(Emittery.listenerAdded).then(({eventName, listener}) => {
-			expectType<string | symbol | undefined>(eventName);
+			expectType<PropertyKey | undefined>(eventName);
 			expectType<AnyListener>(listener);
 		});
 		await ee.once(Emittery.listenerRemoved).then(({eventName, listener}) => {
-			expectType<string | symbol | undefined>(eventName);
+			expectType<PropertyKey | undefined>(eventName);
 			expectType<AnyListener>(listener);
 		});
 	};
@@ -102,13 +102,6 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 	expectAssignable<typeof ee.debug.logger>(myLogger);
 }
 
-// Userland can't emit the meta events
-{
-	const ee = new Emittery();
-	expectError(ee.emit(Emittery.listenerRemoved));
-	expectError(ee.emit(Emittery.listenerAdded));
-}
-
 // Strict typing for emission
 {
 	const ee = new Emittery<{
diff --git a/test/index.js b/test/index.js
index c646624..566c47d 100644
--- a/test/index.js
+++ b/test/index.js
@@ -133,14 +133,15 @@ test('on() - listenerAdded offAny', async t => {
 	t.is(eventName, undefined);
 });
 
-test('on() - eventName must be a string or a symbol', t => {
+test('on() - eventName must be a string, symbol, or number', t => {
 	const emitter = new Emittery();
 
 	emitter.on('string', () => {});
 	emitter.on(Symbol('symbol'), () => {});
+	emitter.on(42, () => {});
 
 	t.throws(() => {
-		emitter.on(42, () => {});
+		emitter.on(true, () => {});
 	}, TypeError);
 });
 
@@ -327,14 +328,15 @@ test('off() - multiple event names', async t => {
 	t.deepEqual(calls, [1, 1]);
 });
 
-test('off() - eventName must be a string or a symbol', t => {
+test('off() - eventName must be a string, symbol, or number', t => {
 	const emitter = new Emittery();
 
 	emitter.on('string', () => {});
 	emitter.on(Symbol('symbol'), () => {});
+	emitter.on(42, () => {});
 
 	t.throws(() => {
-		emitter.off(42);
+		emitter.off(true);
 	}, TypeError);
 });
 
@@ -362,13 +364,14 @@ test('once() - multiple event names', async t => {
 	t.is(await promise, fixture);
 });
 
-test('once() - eventName must be a string or a symbol', async t => {
+test('once() - eventName must be a string, symbol, or number', async t => {
 	const emitter = new Emittery();
 
 	emitter.once('string');
 	emitter.once(Symbol('symbol'));
+	emitter.once(42);
 
-	await t.throwsAsync(emitter.once(42), TypeError);
+	await t.throwsAsync(emitter.once(true), TypeError);
 });
 
 test.cb('emit() - one event', t => {
@@ -407,13 +410,21 @@ test.cb('emit() - multiple events', t => {
 	emitter.emit('🦄');
 });
 
-test('emit() - eventName must be a string or a symbol', async t => {
+test('emit() - eventName must be a string, symbol, or number', async t => {
 	const emitter = new Emittery();
 
 	emitter.emit('string');
 	emitter.emit(Symbol('symbol'));
+	emitter.emit(42);
 
-	await t.throwsAsync(emitter.emit(42), TypeError);
+	await t.throwsAsync(emitter.emit(true), TypeError);
+});
+
+test('emit() - userland cannot emit the meta events', async t => {
+	const emitter = new Emittery();
+
+	await t.throwsAsync(emitter.emit(Emittery.listenerRemoved), TypeError);
+	await t.throwsAsync(emitter.emit(Emittery.listenerAdded), TypeError);
 });
 
 test.cb('emit() - is async', t => {
@@ -584,13 +595,21 @@ test.cb('emitSerial()', t => {
 	emitter.emitSerial('🦄', 'e');
 });
 
-test('emitSerial() - eventName must be a string or a symbol', async t => {
+test('emitSerial() - eventName must be a string, symbol, or number', async t => {
 	const emitter = new Emittery();
 
 	emitter.emitSerial('string');
 	emitter.emitSerial(Symbol('symbol'));
+	emitter.emitSerial(42);
+
+	await t.throwsAsync(emitter.emitSerial(true), TypeError);
+});
+
+test('emitSerial() - userland cannot emit the meta events', async t => {
+	const emitter = new Emittery();
 
-	await t.throwsAsync(emitter.emitSerial(42), TypeError);
+	await t.throwsAsync(emitter.emitSerial(Emittery.listenerRemoved), TypeError);
+	await t.throwsAsync(emitter.emitSerial(Emittery.listenerAdded), TypeError);
 });
 
 test.cb('emitSerial() - is async', t => {
@@ -1002,15 +1021,16 @@ test('listenerCount() - works with empty eventName strings', t => {
 	t.is(emitter.listenerCount(''), 1);
 });
 
-test('listenerCount() - eventName must be undefined if not a string nor a symbol', t => {
+test('listenerCount() - eventName must be undefined if not a string, symbol, or number', t => {
 	const emitter = new Emittery();
 
 	emitter.listenerCount('string');
 	emitter.listenerCount(Symbol('symbol'));
+	emitter.listenerCount(42);
 	emitter.listenerCount();
 
 	t.throws(() => {
-		emitter.listenerCount(42);
+		emitter.listenerCount(true);
 	}, TypeError);
 });