Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support nested event methods #5

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v16
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,60 @@ export const Parent = _Parent & WithEventsDummyType(ParentEvents);
```
This type (`Parent`) is what should be exported and used by other code (_not_ `_Parent`).

### Specifying Paths to Nested Event Locations with `AddEvents`

To accommodate complex class structures where events are organized within nested objects, the `AddEvents` utility allows specifying a path to these nested event locations. This feature enhances the ability to manage events in a structured manner within classes.

#### Enhancing Classes with Nested Events

For classes where events are nested within an object, you can specify the path to these nested events when enhancing your class. This approach helps maintain organized and encapsulated event structures.

**Example: Setting Up Nested Events**

Consider a class with events nested under a property called `eventsContainer`:

```typescript
// Define an interface
interface NestedEvents {
eventOne: TypedEvent<() => void>;
}

// Define a class with nested events
class _Nested {
eventsContainer = {
eventOne: new TypedEvent<() => void>()
};
}

// Enhance the class by specifying the path to nested events
export const Nested = AddEvents<typeof _Nested, NestedEvents>(_Nested, 'eventsContainer');

// Export it as a type as well, to avoid "'Nested' refers to a value, but is being used as a type".
export const Nested = _Nested & WithEventsDummyType(NestedEvents);

// Create an instance and attach event listeners
const instance = new Nested();
instance.on('eventOne', () => console.log('Event one triggered'));
instance.eventsContainer.eventOne.emit();
```

#### Error Handling for Incorrect Paths

If an incorrect path is provided, the utility throws an error. This ensures your event paths are correctly configured and provides immediate feedback for debugging.

**Example: Incorrect Path Configuration**

Here’s how errors are managed when an incorrect path is specified:

```typescript
try {
const InvalidClass = AddEvents<typeof _Nested, NestedEvents>(_Nested, 'incorrectPath');
const wrongInstance = new InvalidClass();
wrongInstance.on('eventOne', () => console.log('This will never run'));
} catch (error) {
console.error(error); // Outputs: Error: Event "incorrectPath.eventOne" is not defined
}
```

## FAQ
##### _Rather than requiring constraints on the type `U` in AddEvents via documentation, why not express them in the type system?_
Expand Down
59 changes: 59 additions & 0 deletions src/event-mixin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,62 @@ describe('a child class', () => {
expect(eventThreeArgs).toHaveLength(1);
});
});

interface NestedEvents {
eventOne: TypedEvent<(value: number) => void>;
eventTwo: TypedEvent<(value: boolean) => void>;
}

interface InvalidPathEvents {
eventOne: TypedEvent<(value: number) => void>;
}
class _Nested {
eventContainer = {
deeper: {
eventOne: new TypedEvent<(value: number) => void>(),
eventTwo: new TypedEvent<(value: boolean) => void>(),
},
};

fireEventOne() {
this.eventContainer.deeper.eventOne.emit(42);
}

fireEventTwo() {
this.eventContainer.deeper.eventTwo.emit(true);
}
}

const Nested = AddEvents<typeof _Nested, NestedEvents>(_Nested, 'eventContainer.deeper');
const InvalidPath = AddEvents<typeof _Nested, InvalidPathEvents>(_Nested, 'eventContainer.unknown');

describe('a nested class with path', () => {
const myClass = new Nested();
it('should notify handlers when nested events are fired', () => {
expect.hasAssertions();
const handlerOneArgs: number[] = [];
const handlerTwoArgs: boolean[] = [];

myClass.on('eventOne', (value: number) => handlerOneArgs.push(value));
myClass.on('eventTwo', (value: boolean) => handlerTwoArgs.push(value));

myClass.fireEventOne();
myClass.fireEventTwo();

expect(handlerOneArgs).toStrictEqual([42]);

expect(handlerTwoArgs).toStrictEqual([true]);
});

it('should throw an error when trying to subscribe to an event with an invalid path', () => {
expect.hasAssertions();

const instance = new InvalidPath();

// Trying to bind an event handler to an invalid path
expect(() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
instance.on('eventOne', () => {});
}).toThrow(new Error('Event "eventContainer.unknown.eventOne" is not defined'));
});
});
47 changes: 41 additions & 6 deletions src/event-mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,47 @@ type eventHandlerType<Type> = Type extends TypedEvent<infer X> ? X : never;
* - The values are of typed 'TypedEvent'.
*
* @param Base - The class which will be extended with event subscription methods.
* @param path - The path to the TypedEvent member in the Base class. This is optional and only needed
* if the events are not directly on the Base class.
* @returns A subclass of Base with event subscription methods added.
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
export function AddEvents<TBase extends Constructor, U>(Base: TBase, path?: string) {
/**
* Get the event object from the given instance. If a path is given, the object will be traversed
* using the path.
*
* @param instance - The instance to get the event object from.
* @returns The event object.
*/
function getEventObject(instance: any) {
if (!path) {
return instance;
}
return path.split('.').reduce((obj, key) => (obj ? obj[key] : undefined), instance);
}

/**
* Get the event object from the given instance. If a path is given, the object will be traversed
* using the path. If the event object is not found, an error will be thrown. Even though we bypass
* type safety in the call (casting instance as any), we've enforced it in the method signature
* above, so it's still safe.
*
* @param instance - The instance to get the event object from.
* @param eventName - The name of the event to get.
* @returns The event.
*/
function getEvent<K extends keyof U>(instance: any, eventName: K) {
const eventsObject = getEventObject(instance);
const event = eventsObject?.[eventName];
if (!event) {
// Construct the full path for the error message
const fullPath = path ? `${path}.${String(eventName)}` : String(eventName);
throw new Error(`Event "${fullPath}" is not defined`);
}
return event;
}

// eslint-disable-next-line jsdoc/require-jsdoc
return class WithEvents extends Base {
/**
Expand All @@ -35,9 +72,7 @@ export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
* @param handler - The handler to invoke when the event is fired.
*/
on<K extends keyof U, E extends eventHandlerType<U[K]>>(eventName: K, handler: E) {
// Even though we bypass type safety in the call (casting this as any), we've enforced it in the
// method signature above, so it's still safe.
(this as any)[eventName].on(handler);
getEvent(this, eventName).on(handler);
}

/**
Expand All @@ -49,7 +84,7 @@ export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
* @param handler - The handler.
*/
once<K extends keyof U, E extends eventHandlerType<U[K]>>(eventName: K, handler: E) {
(this as any)[eventName].once(handler);
getEvent(this, eventName).once(handler);
}

/**
Expand All @@ -61,7 +96,7 @@ export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
* @param handler - The handler.
*/
off<K extends keyof U, E extends eventHandlerType<U[K]>>(eventName: K, handler: E) {
(this as any)[eventName].off(handler);
getEvent(this, eventName).off(handler);
}
};
}
Expand Down
Loading