Lightweight typed event emitter with DOM EventTarget and TC39 Observable compatibility
npm install event-emissionEvent Emission is a high-performance, type-safe event primitive designed to bridge the gap between three worlds: DOM EventTarget, TC39 Observable, and AsyncIterator. While standard event emitters often force you into a single consumption pattern, Event Emission gives you the freedom to dispatch once and consume however your logic demands—whether that's standard callbacks, reactive pipelines via RxJS, or clean for await...of loops. By treating events as a first-class, composable primitive rather than just a side-effect, it eliminates race conditions and shared mutable state, providing a unified, zero-dependency foundation for building resilient, concurrent applications in modern JavaScript and TypeScript environments.
A lightweight, zero-dependency, type-safe event system with DOM EventTarget ergonomics and TC39 Observable interoperability. Use one event source with callbacks, async iterators, and RxJS without losing TypeScript safety.
- Typed events - Event maps keep payloads and event names in sync
- DOM compatible - EmissionEvent is a superset of the built-in Event
- Familiar API - addEventListener, removeEventListener, dispatchEvent
- TC39 Observable - Fully compliant Observable implementation (passes all es-observable-tests)
- Async iteration - for await...of over events with backpressure options
- Wildcard listeners - Listen to or namespaced user: patterns
- Observable state - Proxy any object and emit change events automatically
- AbortSignal support - Cleanup with AbortController
- No dependencies - Framework-agnostic, works in Node, Bun, and browsers
- Typed app-level event buses
- Bridging DOM events to RxJS pipelines
- State objects that emit change events for UI updates
- Component/service emitters without stringly-typed payloads
``bash`
npm install event-emissionor
bun add event-emissionor
pnpm add event-emission
`typescript
import { createEventTarget } from 'event-emission';
type UserEvents = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
error: Error;
};
const events = createEventTarget
events.addEventListener('user:login', (event) => {
console.log(User ${event.detail.userId} logged in at ${event.detail.timestamp});
});
events.dispatchEvent({
type: 'user:login',
detail: { userId: '123', timestamp: new Date() },
});
`
- Event map: a TypeScript type that maps event names to payload types.
- Event shape: { type: string; detail: Payload; bubbles?: boolean; cancelable?: boolean; composed?: boolean } for dispatch.addEventListener
- Unsubscribe: returns a function to remove the listener.
Core (event-emission):
- createEventTargetcreateEventTarget(target, { observe: true, ... })
- EventEmission
- base class
Optional subpaths:
- Observable compliant implementation (event-emission/observable)fromEventTarget
- Interoperability: , forwardToEventTarget, pipe (event-emission/interoperability)isObserved
- Observe utilities: , getOriginal, setupEventForwarding (event-emission/observe)event-emission/types
- Types-only exports ()
To keep the core entrypoint lean, optional features are exposed via subpath exports:
`typescript`
import { Observable } from 'event-emission/observable';
import { getOriginal, isObserved, setupEventForwarding } from 'event-emission/observe';
import {
forwardToEventTarget,
fromEventTarget,
pipe,
} from 'event-emission/interoperability';
import type { EventTargetLike } from 'event-emission/types';
Creates a typed event target.
`typescript
type Events = {
message: { text: string };
error: Error;
};
const target = createEventTarget
`
#### Options
| Option | Type | Description |
| ----------------- | ---------------------------------------- | -------------------------------------------- |
| onListenerError | (type: string, error: unknown) => void | Custom error handler for listener exceptions |
If a listener throws and no onListenerError is provided, an error event is emitted. If there are no error listeners, the error is re-thrown.
Create state objects that emit change events:
`typescript
const state = createEventTarget({ count: 0, user: { name: 'Ada' } }, { observe: true });
state.addEventListener('update', (event) => {
console.log('State changed:', event.detail.current);
});
state.addEventListener('update:count', (event) => {
console.log(
Count changed from ${event.detail.previous.count} to ${event.detail.value},
);
});
state.count = 1; // Triggers 'update' and 'update:count'
state.user.name = 'Grace'; // Triggers 'update' and 'update:user.name'
`
Observe options:
Deep observation is enabled by default.
| Option | Type | Default | Description |
| --------------- | ------------------------------- | -------- | ------------------------------------------------------------------ |
| observe | boolean | false | Enable property change observation |deep
| | boolean | true | Observe nested objects |cloneStrategy
| | 'shallow' \| 'deep' \| 'path' | 'path' | How to clone previous state |deepClone
| | | - | Optional deep clone fallback when structuredClone is unavailable |
Note: cloneStrategy: 'deep' uses structuredClone by default, or deepClone if provided.
Example fallback:
`typescript`
const state = createEventTarget(
{ count: 0, user: { name: 'Ada' } },
{
observe: true,
cloneStrategy: 'deep',
deepClone: (value) => JSON.parse(JSON.stringify(value)),
},
);
Update event details:
- update and update:path events include { value, current, previous }.update:items.push
- Array mutators emit method events like with { method, args, added, removed, current, previous }.
Creates an Observable for a specific event type. This follows the ObservableEventTarget proposal, allowing for powerful composition.
`typescript
const clicks = button.on('click', { passive: true });
clicks.subscribe((event) => {
console.log('Clicked!', event.detail);
});
`
Options:
| Option | Type | Default | Description |
| -------------- | ------------- | ------- | -------------------------------------------------------------------------------------- |
| capture | boolean | false | If true, listen during the capture phase |receiveError
| | boolean | false | If true, listen for "error" events and forward them to the observer's error method |handler
| | Function | null | Optional function to run stateful actions (like preventDefault()) before dispatching |once
| | boolean | false | If true, the observable completes after the first event is dispatched |passive
| | boolean | false | Indicates that the callback will not cancel the event |signal
| | AbortSignal | - | Abort signal to remove the listener when aborted |
Adds a listener and returns an unsubscribe function.
`typescript
const unsubscribe = events.addEventListener('message', (event) => {
console.log(event.detail.text);
});
unsubscribe();
`
Options: (or pass true/false for capture)
| Option | Type | Description |
| --------- | ------------- | -------------------------------------------- |
| capture | boolean | Listen during the capture phase |once
| | boolean | Remove listener after first invocation |passive
| | boolean | Listener will not call preventDefault() |signal
| | AbortSignal | Abort signal to remove listener when aborted |
Note: preventDefault() only affects events dispatched with cancelable: true. dispatchEvent returns false when a cancelable event is prevented.
Adds a one-time listener.
Removes a specific listener.
Removes all listeners, or all listeners for a type.
`typescriptGot ${event.originalType}:
events.addWildcardListener('*', (event) => {
console.log(, event.detail);
});
events.addWildcardListener('user:*', (event) => {
console.log(User event: ${event.originalType});`
});
Wildcard events include { type: pattern, originalType, detail }.
You can use for await...of to consume events. This is great for stream-processing events with backpressure.
`typescript
// Simple iteration
for await (const event of events.events('message')) {
console.log('Received:', event.detail.text);
}
// With options for backpressure and cleanup
const iterator = events.events('message', {
bufferSize: 16,
overflowStrategy: 'drop-oldest',
signal: abortController.signal,
});
for await (const event of iterator) {
// ...
}
`
Iterator options:
| Option | Type | Default | Description |
| ------------------ | ------------------------------------------- | --------------- | ------------------------------ |
| signal | AbortSignal | - | Abort signal to stop iteration |bufferSize
| | number | Infinity | Maximum buffered events |overflowStrategy
| | 'drop-oldest' \| 'drop-latest' \| 'throw' | 'drop-oldest' | Behavior when buffer is full |
When overflowStrategy is throw, the iterator throws BufferOverflowError.
`typescript
const subscription = events.subscribe('message', {
next: (event) => console.log(event.detail),
error: (err) => console.error(err),
complete: () => console.log('Done'),
});
subscription.unsubscribe();
`
Returns an Observable that emits all events.
`typescript
import { from } from 'rxjs';
import { filter, map } from 'rxjs/operators';
const observable = from(events);
observable
.pipe(
filter((event) => event.type === 'message'),
map((event) => event.detail.text),
)
.subscribe(console.log);
`
A fully compliant implementation of the TC39 Observable proposal.
`typescript
import { Observable } from 'event-emission/observable';
// Create from items
const numbers = Observable.of(1, 2, 3);
// Create from any iterable or observable-like
const fromArray = Observable.from([10, 20, 30]);
// Manual creation
const custom = new Observable((observer) => {
observer.next('Hello');
observer.complete();
});
`
Marks the event target as complete, clears listeners, and ends iterators.
Removes all listeners without marking as complete.
Extend EventEmission to build typed emitters:
`typescript
import { EventEmission } from 'event-emission';
class UserService extends EventEmission<{
'user:created': { id: string; name: string };
'user:deleted': { id: string };
error: Error;
}> {
createUser(name: string) {
const id = crypto.randomUUID();
this.dispatchEvent({ type: 'user:created', detail: { id, name } });
return id;
}
}
`
`typescript
import { fromEventTarget } from 'event-emission/interoperability';
type ButtonEvents = {
click: MouseEvent;
focus: FocusEvent;
};
const button = document.getElementById('my-button');
const events = fromEventTarget
events.addEventListener('click', (event) => {
console.log('Button clicked!', event.detail);
});
events.destroy();
`
`typescript
import { createEventTarget } from 'event-emission';
import { forwardToEventTarget } from 'event-emission/interoperability';
const events = createEventTarget<{ custom: { value: number } }>();
const element = document.getElementById('target');
const unsubscribe = forwardToEventTarget(events, element);
events.dispatchEvent({ type: 'custom', detail: { value: 42 } });
unsubscribe();
`
`typescript
import { createEventTarget } from 'event-emission';
import { pipe } from 'event-emission/interoperability';
const componentEvents = createEventTarget<{ ready: void }>();
const appBus = createEventTarget<{ ready: void }>();
const unsubscribe = pipe(componentEvents, appBus);
unsubscribe();
`
Note: Both pipe(source, target) and events.pipe(target) forward all events via a wildcard listener. Use a map function to transform events or return null to filter.
Event Emission works beautifully with React's useSyncExternalStore for predictable, race-condition-free state synchronization.
`typescript
import { useSyncExternalStore } from 'react';
import { createEventTarget } from 'event-emission';
const bus = createEventTarget<{ log: string }>();
function useBusEvent() {
return useSyncExternalStore(
(callback) => bus.addEventListener('log', callback),
() => getLatestLogValue(), // implementation depends on your needs
);
}
`
The observe feature is perfect for building high-performance global stores or local controllers that live outside the React render cycle.
`typescript
import { useSyncExternalStore } from 'react';
import { createEventTarget } from 'event-emission';
// 1. Create your store outside of React
const store = createEventTarget(
{ count: 0, lastUpdated: new Date() },
{ observe: true }
);
// 2. Create a generic hook to sync with any observed target
export function useObservable
return useSyncExternalStore(
(onStoreChange) => (target as any).addEventListener('update', onStoreChange),
() => target
);
}
// 3. Components re-render only when the store actually mutates
function Counter() {
const state = useObservable(store);
return (
);
}
`
Event Emission works naturally with Svelte 5 Runes to create reactive stores that live outside your component tree.
`typescript
import { createEventTarget } from 'event-emission';
// 1. Define your external state
const store = createEventTarget({ count: 0 }, { observe: true });
// 2. Create a generic Rune to sync with any observed target
export function useObservable
let state = $state(target);
$effect(() => {
return (target as any).addEventListener('update', () => {
state = target; // Trigger Svelte reactivity
});
});
return state;
}
// 3. Use it in your components
const state = useObservable(store);
`
Checks if an object is an observed proxy.
`typescript`
import { isObserved } from 'event-emission/observe';
Returns the original unproxied object.
`typescript`
import { getOriginal } from 'event-emission/observe';
`typescript
import type {
EmissionEvent,
EventTargetLike,
ObservableLike,
Observer,
Subscription,
WildcardEvent,
AddEventListenerOptionsLike,
} from 'event-emission/types';
import type {
ObservableEventMap,
PropertyChangeDetail,
ArrayMutationDetail,
} from 'event-emission/observe';
import type { Subscriber, SubscriptionObserver } from 'event-emission/observable';
``