Repository: sindresorhus/emittery (js)

Issue:
Add convenience method to bind Emittery methods to own instance
<!-- Issuehunt Badges -->

[<img alt="Issuehunt badges" src="https://issuehunt.io/r/sindresorhus/emittery/issues/30/badge.svg" />](https://issuehunt.io/r/sindresorhus/emittery/issues/30)

<!-- /Issuehunt Badges -->

`EventEmitter` in Node.js is usually subclassed, but I don't like this pattern, and I don't think we should recommend it. It's better to have an internal Emittery instance and just explicitly expose some of the methods.

We could make this even easier, by providing a convenience method to bind the Emittery method to the instance:

```js
class Foo {
	constructor() [
		this.emitter = new Emittery();
		this.emitter.bindMethods(this, ['on', 'off']);
	]
}
```

The above would quickly expose the `on` and `off` methods, but nothing else.
<!-- Issuehunt content -->



> There is a $60.00 open bounty on this issue. Add more on [Issuehunt](https://issuehunt.io/r/sindresorhus/emittery/issues/30).
> - Checkout the [Issuehunt explorer](https://issuehunt.io/r/sindresorhus/emittery/) to discover more funded issues.
> - Need some help from other developers? [Add your repositories](https://issuehunt.io/r/new) on Issuehunt to raise funds.


<!-- /Issuehunt content-->

Pull request:
Add `bindMethods()` method and a `mixin()` decorator (TS)
Fixes #30

Patch:
diff --git a/index.d.ts b/index.d.ts
index cdecfad..95eaee2 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,4 +1,21 @@
 declare class Emittery {
+	/**
+	 * In TypeScript, returns a decorator which mixins `Emittery` as property `emitteryPropertyName` and `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the target class.
+	 *
+	 * @example
+	 * ```
+	 * import Emittery = require('emittery');
+	 *
+	 * @Emittery.mixin('emittery')
+	 * class MyClass {}
+	 *
+	 * let instance = new MyClass();
+	 *
+	 * instance.emit('event');
+	 * ```
+	 */
+	static mixin(emitteryPropertyName: string, methodNames?: readonly string[]): Function;
+
 	/**
 	 * Subscribe to an event.
 	 *
@@ -74,6 +91,22 @@ declare class Emittery {
 	 * specified.
 	 */
 	listenerCount(eventName?: string): number;
+
+	/**
+	 * Bind the given `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the `target` object.
+	 *
+	 * @example
+	 * ```
+	 * import Emittery = require('emittery');
+	 *
+	 * let object = {};
+	 *
+	 * new Emittery().bindMethods(object);
+	 *
+	 * object.emit('event');
+	 * ```
+	 */
+	bindMethods(target: object, methodNames?: readonly string[]): void;
 }
 
 declare namespace Emittery {
diff --git a/index.js b/index.js
index 1f09455..e666671 100644
--- a/index.js
+++ b/index.js
@@ -25,7 +25,70 @@ function getListeners(instance, eventName) {
 	return events.get(eventName);
 }
 
+function defaultMethodNamesOrAssert(methodNames) {
+	if (methodNames === undefined) {
+		return allEmitteryMethods;
+	}
+
+	if (!Array.isArray(methodNames)) {
+		throw new TypeError('`methodNames` must be an array of strings');
+	}
+
+	for (const methodName of methodNames) {
+		if (!allEmitteryMethods.includes(methodName)) {
+			if (typeof methodName !== 'string') {
+				throw new TypeError('`methodNames` element must be a string');
+			}
+
+			throw new Error(`${methodName} is not Emittery method`);
+		}
+	}
+
+	return methodNames;
+}
+
 class Emittery {
+	static mixin(emitteryPropertyName, methodNames) {
+		methodNames = defaultMethodNamesOrAssert(methodNames);
+		return target => {
+			if (typeof target !== 'function') {
+				throw new TypeError('`target` must be function');
+			}
+
+			for (const methodName of methodNames) {
+				if (target.prototype[methodName] !== undefined) {
+					throw new Error(`The property \`${methodName}\` already exists on \`target\``);
+				}
+			}
+
+			function getEmitteryProperty() {
+				Object.defineProperty(this, emitteryPropertyName, {
+					enumerable: false,
+					value: new Emittery()
+				});
+				return this[emitteryPropertyName];
+			}
+
+			Object.defineProperty(target.prototype, emitteryPropertyName, {
+				enumerable: false,
+				get: getEmitteryProperty
+			});
+
+			const emitteryMethodCaller = methodName => function (...args) {
+				return this[emitteryPropertyName][methodName](...args);
+			};
+
+			for (const methodName of methodNames) {
+				Object.defineProperty(target.prototype, methodName, {
+					enumerable: false,
+					value: emitteryMethodCaller(methodName)
+				});
+			}
+
+			return target;
+		};
+	}
+
 	constructor() {
 		anyMap.set(this, new Set());
 		eventsMap.set(this, new Map());
@@ -140,8 +203,29 @@ class Emittery {
 
 		return count;
 	}
+
+	bindMethods(target, methodNames) {
+		if (typeof target !== 'object' || target === null) {
+			throw new TypeError('`target` must be an object');
+		}
+
+		methodNames = defaultMethodNamesOrAssert(methodNames);
+
+		for (const methodName of methodNames) {
+			if (target[methodName] !== undefined) {
+				throw new Error(`The property \`${methodName}\` already exists on \`target\``);
+			}
+
+			Object.defineProperty(target, methodName, {
+				enumerable: false,
+				value: this[methodName].bind(this)
+			});
+		}
+	}
 }
 
+const allEmitteryMethods = Object.getOwnPropertyNames(Emittery.prototype).filter(v => v !== 'constructor');
+
 // Subclass used to encourage TS users to type their events.
 Emittery.Typed = class extends Emittery {};
 Object.defineProperty(Emittery.Typed, 'Typed', {
diff --git a/readme.md b/readme.md
index a4fdffa..88a3792 100644
--- a/readme.md
+++ b/readme.md
@@ -103,6 +103,20 @@ If `eventName` is given, only the listeners for that event are cleared.
 
 The number of listeners for the `eventName` or all events if not specified.
 
+#### bindMethods(target, methodNames?)
+
+Bind the given `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the `target` object.
+
+```js
+import Emittery = require('emittery');
+
+let object = {};
+
+new Emittery().bindMethods(object);
+
+object.emit('event');
+```
+
 
 ## TypeScript
 
@@ -119,6 +133,21 @@ ee.emit('value', 1); // TS compilation error
 ee.emit('end'); // TS compilation error
 ```
 
+### Emittery.mixin(emitteryPropertyName, methodNames?)
+
+A decorator which mixins `Emittery` as property `emitteryPropertyName` and `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the target class.
+
+```ts
+import Emittery = require('emittery');
+
+@Emittery.mixin('emittery')
+class MyClass {}
+
+let instance = new MyClass();
+
+instance.emit('event');
+```
+
 
 ## Scheduling details
 


Test patch:
diff --git a/test/index.js b/test/index.js
index e8e336a..1a62cb7 100644
--- a/test/index.js
+++ b/test/index.js
@@ -411,3 +411,143 @@ test('listenerCount() - eventName must be undefined if not a string', t => {
 		emitter.listenerCount(42);
 	}, TypeError);
 });
+
+test('bindMethods()', t => {
+	const methodsToBind = ['on', 'off', 'emit', 'listenerCount'];
+
+	const emitter = new Emittery();
+	const target = {};
+
+	const oldPropertyNames = Object.getOwnPropertyNames(target);
+	emitter.bindMethods(target, methodsToBind);
+
+	t.deepEqual(Object.getOwnPropertyNames(target).sort(), oldPropertyNames.concat(methodsToBind).sort());
+
+	for (const method of methodsToBind) {
+		t.is(typeof target[method], 'function');
+	}
+
+	t.is(target.listenerCount(), 0);
+});
+
+test('bindMethods() - methodNames must be array of strings or undefined', t => {
+	t.throws(() => {
+		new Emittery().bindMethods({}, null);
+	});
+
+	t.throws(() => {
+		new Emittery().bindMethods({}, 'string');
+	});
+
+	t.throws(() => {
+		new Emittery().bindMethods({}, {});
+	});
+
+	t.throws(() => {
+		new Emittery().bindMethods({}, [null]);
+	});
+
+	t.throws(() => {
+		new Emittery().bindMethods({}, [1]);
+	});
+
+	t.throws(() => {
+		new Emittery().bindMethods({}, [{}]);
+	});
+});
+
+test('bindMethods() - must bind all methods if no array supplied', t => {
+	const methodsExpected = ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'];
+
+	const emitter = new Emittery();
+	const target = {};
+
+	const oldPropertyNames = Object.getOwnPropertyNames(target);
+	emitter.bindMethods(target);
+
+	t.deepEqual(Object.getOwnPropertyNames(target).sort(), oldPropertyNames.concat(methodsExpected).sort());
+
+	for (const method of methodsExpected) {
+		t.is(typeof target[method], 'function');
+	}
+
+	t.is(target.listenerCount(), 0);
+});
+
+test('bindMethods() - methodNames must only include Emittery methods', t => {
+	const emitter = new Emittery();
+	const target = {};
+	t.throws(() => emitter.bindMethods(target, ['noexistent']));
+});
+
+test('bindMethods() - must not set already existing fields', t => {
+	const emitter = new Emittery();
+	const target = {
+		on: true
+	};
+	t.throws(() => emitter.bindMethods(target, ['on']));
+});
+
+test('bindMethods() - target must be an object', t => {
+	const emitter = new Emittery();
+	t.throws(() => emitter.bindMethods('string', []));
+	t.throws(() => emitter.bindMethods(null, []));
+	t.throws(() => emitter.bindMethods(undefined, []));
+});
+
+test('mixin()', t => {
+	class TestClass {
+		constructor(v) {
+			this.v = v;
+		}
+	}
+	const TestClassWithMixin = Emittery.mixin('emitter', ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'])(TestClass);
+	const symbol = Symbol('test symbol');
+	const instance = new TestClassWithMixin(symbol);
+	t.true(instance.emitter instanceof Emittery);
+	t.true(instance instanceof TestClass);
+	t.is(instance.emitter, instance.emitter);
+	t.is(instance.v, symbol);
+	t.is(instance.listenerCount(), 0);
+});
+
+test('mixin() - methodNames must be array of strings or undefined', t => {
+	class TestClass {
+	}
+	t.throws(() => Emittery.mixin('emitter', null)(TestClass));
+	t.throws(() => Emittery.mixin('emitter', 'string')(TestClass));
+	t.throws(() => Emittery.mixin('emitter', {})(TestClass));
+	t.throws(() => Emittery.mixin('emitter', [null])(TestClass));
+	t.throws(() => Emittery.mixin('emitter', [1])(TestClass));
+	t.throws(() => Emittery.mixin('emitter', [{}])(TestClass));
+});
+
+test('mixin() - must mixin all methods if no array supplied', t => {
+	const methodsExpected = ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'];
+
+	class TestClass {}
+	const TestClassWithMixin = Emittery.mixin('emitter')(TestClass);
+
+	t.deepEqual(Object.getOwnPropertyNames(TestClassWithMixin.prototype).sort(), methodsExpected.concat(['constructor', 'emitter']).sort());
+});
+
+test('mixin() - methodNames must only include Emittery methods', t => {
+	class TestClass {}
+	t.throws(() => Emittery.mixin('emitter', ['nonexistent'])(TestClass));
+});
+
+test('mixin() - must not set already existing methods', t => {
+	class TestClass {
+		on() {
+			return true;
+		}
+	}
+	t.throws(() => Emittery.mixin('emitter', ['on'])(TestClass));
+});
+
+test('mixin() - target must be function', t => {
+	t.throws(() => Emittery.mixin('emitter')('string'));
+	t.throws(() => Emittery.mixin('emitter')(null));
+	t.throws(() => Emittery.mixin('emitter')(undefined));
+	t.throws(() => Emittery.mixin('emitter')({}));
+});