Event Emitter Core
A tiny, type-safe synchronous event emitter shared across Web Loom packages. The internal primitive behind Event Bus Core, Forms Core, Media Core, and more.
Event Emitter Core
@web-loom/event-emitter-core is a tiny, zero-dependency, type-safe synchronous event emitter. It is the internal primitive that powers the listener bookkeeping in @web-loom/event-bus-core, @web-loom/forms-core, @web-loom/media-core, @web-loom/storage-core, and @web-loom/notifications-core. It can also be used directly when you need a lightweight emitter without the pub-sub semantics of the Event Bus.
Features
- Type-safe events — define an event map and get full autocomplete on event names, payload shapes, and listener signatures.
- Synchronous fan-out — all listeners for an event are called in registration order before
emitreturns. - Listener lifecycle —
on,once,off, and cleanup aliases cover every common pattern. - Error isolation — listener errors are caught and forwarded to a configurable
onErrorhandler; they never crash other listeners. - Runtime inspection —
listenerCount,hasListeners, andeventNamesfor debugging and testing. - Zero dependencies — pure TypeScript, no runtime imports.
Installation
npm install @web-loom/event-emitter-coreCore Concepts
Event Maps
An event map is a plain TypeScript type whose keys are event names and values describe the payload:
type MediaEvents = {
play: void; // no payload
progress: [currentTime: number, duration: number]; // tuple payload
error: Error; // single value payload
};void— listener takes no arguments.- A plain type like
Error— listener receives one argument of that type. - A tuple — listener receives the tuple spread as individual arguments.
EventEmitter class
import { EventEmitter } from '@web-loom/event-emitter-core';
const emitter = new EventEmitter<MediaEvents>();Pass an optional EventEmitterOptions object to override the default error handler:
const emitter = new EventEmitter<MediaEvents>({
onError: (error, eventName) => {
logger.error(`Listener for "${String(eventName)}" threw`, error);
},
});API Reference
on(event, listener)
Registers a listener and returns an unsubscribe callback.
const unsubscribe = emitter.on('progress', (current, total) => {
console.log(`${current} / ${total}`);
});
// Later
unsubscribe();once(event, listener)
Same as on but automatically unregisters after the first emission.
emitter.once('play', () => {
console.log('First play event');
});emit(event, ...payload)
Synchronously calls all registered listeners for the event in registration order.
emitter.emit('progress', 16, 100); // calls all 'progress' listeners
emitter.emit('play'); // no payload required
emitter.emit('error', new Error('stream failed'));off(event?, listener?)
Removes a specific listener, all listeners for an event, or all listeners entirely.
emitter.off('progress', myListener); // remove one listener
emitter.off('progress'); // remove all listeners for 'progress'
emitter.off(); // remove all listenersCleanup aliases
All of the following are equivalent ways to remove all listeners for an event or globally:
emitter.removeAllListeners('progress'); // by event
emitter.removeAllListeners(); // all
emitter.unsubscribeAll(); // alias
emitter.removeAll(); // alias
emitter.clear(); // aliasunsubscribe(event, listener?) is also available as an alias for off.
Runtime inspection
emitter.listenerCount('progress'); // → number of registered listeners
emitter.hasListeners('progress'); // → true / false
emitter.eventNames(); // → array of events with at least one listenerTypeScript Exports
import type {
EventRecord, // base constraint for event maps: Record<PropertyKey, unknown>
EventArgs, // derives the listener argument tuple from an event map key
EventListener, // typed listener function
EventEmitterOptions, // constructor options
EventSubscription, // unsubscribe callback: () => void
} from '@web-loom/event-emitter-core';EventArgs utility type
EventArgs<TEvents, TKey> resolves to the spread argument list a listener for that event should accept:
type Args = EventArgs<MediaEvents, 'progress'>; // → [currentTime: number, duration: number]
type Args = EventArgs<MediaEvents, 'play'>; // → []
type Args = EventArgs<MediaEvents, 'error'>; // → [Error]Usage Examples
Media player events
import { EventEmitter } from '@web-loom/event-emitter-core';
type PlayerEvents = {
play: void;
pause: void;
seek: [position: number];
ended: void;
error: Error;
timeupdate: [currentTime: number, duration: number];
};
export class MediaPlayerCore {
private emitter = new EventEmitter<PlayerEvents>();
on = this.emitter.on.bind(this.emitter);
off = this.emitter.off.bind(this.emitter);
play() {
// ... audio/video logic
this.emitter.emit('play');
}
seek(position: number) {
// ... seek logic
this.emitter.emit('seek', position);
}
dispose() {
this.emitter.clear();
}
}
const player = new MediaPlayerCore();
const unsubPlay = player.on('play', () => console.log('playing'));
const unsubSeek = player.on('seek', (pos) => console.log(`seeked to ${pos}s`));
player.play(); // → "playing"
player.seek(42); // → "seeked to 42s"
unsubPlay();
unsubSeek();Form lifecycle events
type FormEvents = {
change: [field: string, value: unknown];
submit: void;
reset: void;
validate:[errors: Record<string, string>];
};
const formEmitter = new EventEmitter<FormEvents>();
formEmitter.on('change', (field, value) => {
console.log(`${field} changed to`, value);
});
formEmitter.once('submit', () => {
// fires only on the first submit
analytics.track('form_submitted');
});
formEmitter.emit('change', 'email', 'user@example.com');
formEmitter.emit('submit');Using [object Object] for one-time initialization
const bootEmitter = new EventEmitter<{ ready: void; error: Error }>();
bootEmitter.once('ready', () => {
console.log('App ready — this fires exactly once');
});
bootEmitter.emit('ready'); // fires the listener
bootEmitter.emit('ready'); // no-op — listener was removedCustom error handler
const safeEmitter = new EventEmitter<{ data: string[] }>({
onError: (err, name) => {
errorReporter.capture(err, { event: String(name) });
},
});
safeEmitter.on('data', (items) => {
throw new Error('listener bug'); // caught, reported, does not propagate
});
safeEmitter.emit('data', ['a', 'b']); // error is captured, not thrownRelationship with Event Bus Core
@web-loom/event-bus-core is the application-level pub-sub bus — typed topic channels, cross-feature messaging, and a global singleton. @web-loom/event-emitter-core is the low-level primitive used internally for listener bookkeeping.
Use EventEmitter directly when:
- You own both the emitter and the consumers (e.g., inside a class like
MediaPlayerCore). - You need a per-instance emitter rather than a shared global bus.
- You want zero overhead and no bus abstraction.
Use EventBus when:
- Multiple unrelated features need to communicate without direct coupling.
- You want a typed channel registry that spans the whole application.
Best Practices
- Define event maps as
typealiases to keep them reusable. - Always store the unsubscribe callback from
onand call it in cleanup (component unmount, classdispose, etc.). - Use
oncefor events that should only trigger initialization logic. - Prefer
clear()in a classdispose()method over tracking individual unsubscribes. - Do not emit events from inside a listener for the same event — this causes re-entrant calls and is rarely intentional.