Skip to content

Commit

Permalink
Add bindMethods() method and a mixin() decorator (TS) (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
Yanis Benson authored and sindresorhus committed Jun 15, 2019
1 parent a0a4c6e commit 96a2cb8
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 0 deletions.
33 changes: 33 additions & 0 deletions 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.
*
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions index.js
Expand Up @@ -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());
Expand Down Expand Up @@ -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', {
Expand Down
29 changes: 29 additions & 0 deletions readme.md
Expand Up @@ -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

Expand All @@ -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

Expand Down
140 changes: 140 additions & 0 deletions test/index.js
Expand Up @@ -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')({}));
});

0 comments on commit 96a2cb8

Please sign in to comment.