@spearwolf/eventize
v5.1.0
Published
A tiny, clever, and dependency-free library for synchronous event-driven programming in JavaScript and TypeScript.
Readme
@spearwolf/eventize
A tiny, clever, and dependency-free library for synchronous event-driven programming in JavaScript and TypeScript.
Introduction 👀
@spearwolf/eventize provides a powerful and intuitive API for building event-based systems. This library invokes event listeners synchronously. That design choice gives you precise control over your execution flow, which is critical in scenarios like game loops (requestAnimationFrame), real-time applications, or anywhere immediate, predictable execution is necessary.
Written entirely in TypeScript and targeting modern ES2022, it offers a type-safe developer experience without sacrificing performance or adding bloat.
Features
- 🚀 Developer-Focused API: Clean, modern, and functional.
- ✨ Wildcards & Priorities: Subscribe to all events and control listener execution order.
- 🔷 Full TypeScript Support: Optional generic event maps narrow
emit,on, retained-event names and listener arguments — without losing first-class duck-typing for code that doesn't opt in. - 📦 Zero Runtime Dependencies: Lightweight with a minimal footprint (~5 kB gzipped).
- ESM & CommonJS Support.
- Apache 2.0 Licensed.
⚙️ Installation
$ npm install @spearwolf/eventizeThe library is distributed in both ES Module (import) and CommonJS (require) formats.
[!NOTE] Since version 3.0.0 there is also a CHANGELOG
🤖 For AI coding agents
This repo ships a quick-reference skill for AI coding assistants (Claude Code & co.) at skills/using-eventize/. SKILL.md carries the mental model, the API surface, the four behavior families and the pitfalls; deeper material sits in references/ and is loaded only when a task needs it:
| Reference | Covers |
| --- | --- |
| api-details.md | every on() / off() shape, per-event priorities, retain semantics in full |
| typed-events.md | generic event maps, the EventMap trap, symbol escape hatch |
| migration.md | v4 → v5 emit change, the v4.3 type-brand migration for classes |
To use it, copy or symlink the folder into your agent's skills directory, e.g. for Claude Code:
ln -s "$(pwd)/skills/using-eventize" ~/.claude/skills/using-eventizeSkills are auto-discovered — no extra registration step.
📖 Getting Started
The core idea is simple: an object, called an emitter, can be "eventized" to emit named events. Other parts of your application, called listeners, subscribe to those events and run immediately when the event is emitted.
import {eventize, on, emit} from '@spearwolf/eventize';
// 1. Create an eventized object (the emitter)
const bus = eventize({});
// 2. Subscribe to a 'data' event
on(bus, 'data', (message, code) => {
console.log(`Received message: ${message} with code ${code}`);
});
// 3. Emit the 'data' event with some arguments
emit(bus, 'data', 'Hello World!', 42);
// Output: Received message: Hello World! with code 42The Event-Driven Model
Emitters
An emitter is any object that has been enhanced with event capabilities. The recommended way to create one is the eventize() function.
[!TIP] We often use
ε(epsilon) as a variable name to denote an eventized object.
import {eventize} from '@spearwolf/eventize';
const ε = eventize(); // from a new empty object
const myApp = {name: 'MyApp'};
eventize(myApp); // myApp is now an emitterListeners
A listener can be a function or a method on an object.
on(ε, 'foo', (a) => {
console.log('(1) Hello', a);
});
on(ε, 'foo', {
foo(a, b) {
console.log('(2)', b, a);
},
});
on(ε, {
foo(a, b) {
console.log('(3) Hi', a);
},
bar() {
console.log('(4) hej');
},
});
emit(ε, 'foo', 'eventize', 'Greetings from');
// => "(1) Hello eventize"
// => "(2) Greetings from eventize"
// => "(3) Hi eventize"
emit(ε, 'bar');
// => "(4) hej"Events
Events are identified by a name, which can be a string or a symbol. Anywhere a name is accepted, an array of names works too.
emit(ε, 'user-login'); // no data
emit(ε, 'update', {id: 1, payload: 'new data'}); // one argument
emit(ε, 'hello', 'hi', 'hej', 'hallo'); // several arguments📚 API Reference
The API is designed to be used functionally, with named exports like on(ε, …) and emit(ε, …). For class-based patterns you can inject the same API as methods.
| API | Description |
| ------------- | -------------------------------------------------------------------- |
| on | subscribe to events |
| once | subscribe to the next event only |
| onceAsync | the async version of subscribe only to the next event |
| emit | dispatch an event |
| emitAsync | dispatch an event and wait for any promises returned by subscribers |
| off | unsubscribe |
| retain | hold the last event until it is received by a subscriber |
| retainClear | clear the last event |
| unretain | remove the retain policy entirely (clears value and disables retain) |
Creating Emitters
| Method | Is a EventizedObject? | Has API Methods Injected? | Recommended For |
| --------------------------- | ----------------------- | ------------------------- | ------------------------------------------- |
| eventize(obj) | ✅ | ❌ | Functional programming, general use. |
| eventize.inject(obj) | ✅ | ✅ | Object-oriented or class-based composition. |
| class extends Eventize {} | ✅ | ✅ | Class-based inheritance. |
eventize(obj)
The primary and recommended approach — it prepares an object for the functional API.
import {eventize, on, emit} from '@spearwolf/eventize';
const ε = eventize(); // creates an emitter from {}
on(ε, 'foo', () => console.log('foo called'));
emit(ε, 'foo'); // => "foo called"eventize.inject(obj)
Modifies the object, attaching the entire API as methods.
const myApp = {name: 'MyApp'};
const obj = eventize.inject(myApp);
obj.on('foo', () => console.log('foo called'));
obj.emit('foo'); // => "foo called"class extends Eventize
import {Eventize} from '@spearwolf/eventize';
class MyEmitter extends Eventize {}
const obj = new MyEmitter();
obj.on('foo', () => console.log('foo called'));
obj.emit('foo'); // => "foo called"Class-based, but without inheritance
Call eventize.inject in the constructor instead:
import {eventize, Eventize} from '@spearwolf/eventize';
interface Foo extends Eventize {}
class Foo {
constructor() {
eventize.inject(this);
}
}The four behavior families
Eventize splits its API into four families by how each function treats a target that was never eventized. This is by design:
| Function | On a non-eventized object |
| ------------------------------------------- | -------------------------------------------- |
| on(), once(), onceAsync(), retain() | Auto-eventizes the object |
| emit(), emitAsync() (v5+) | Duck-types: calls obj[eventName](...args) |
| off(), getSubscriptionCount() | Silently does nothing / returns 0 |
| retainClear(), unretain() | Throws "object is not eventized" |
Why the split?
on / once / retain install behavior. Requiring an explicit eventize(obj) before every on(obj, …) would be pure ceremony, so they auto-eventize: on({}, 'foo', fn) is a perfectly meaningful intent.
emit / emitAsync fire events. On an eventized target they dispatch to subscribed listeners. On a non-eventized object (v5+) they fall back to duck-typing — the same pattern that already powers listener-object dispatch:
- If
obj[eventName]is a function → call it with the args (withthis === obj). - Else if
obj.emitis a function → callobj.emit(eventName, ...args). - Otherwise → silently no-op.
That lets you point emit() at adapters, mocks, or plain method-bags without ceremony. null / undefined / non-object targets silently no-op. '*' still throws — it remains subscribe-only.
retainClear / unretain operate on retain state that only exists on eventized objects. There is no meaningful duck-typed equivalent, so they keep throwing — pointing them at a plain {} is almost always a bug.
off is permissive because cleanup code routinely runs against objects whose lifecycle isn't fully under the caller's control. getSubscriptionCount follows the same reasoning and returns 0 for any non-eventized input.
// ✅ Auto-eventize: convenient, intent is clear
const obj = {};
on(obj, 'foo', () => console.log('foo')); // obj is now eventized
emit(obj, 'foo'); // works (dispatches to listener)
// ✅ Duck-typing (v5+): point emit() at a plain method-bag
const sink = {
foo(msg) {
console.log('foo:', msg);
},
};
emit(sink, 'foo', 'hello'); // => "foo: hello"
emit(sink, 'missing'); // no-op (no method, no .emit fallback)
// ✅ off() is permissive — safe in cleanup paths
off({}); // no-op, no throw
// ❌ Strict: retain-state mutators still surface typos
retainClear({}, 'foo'); // throws: "object is not eventized"
unretain({}, 'foo'); // throws: "object is not eventized"The type guard isEventized(obj) (see Utilities) lets you check defensively when you need to.
Migration from v4 → v5: Previously
emit()/emitAsync()also threw"object is not eventized"on a non-eventized target. If you relied on that as a typo-safety net, either gate the call withisEventized()or use a typed emitter (eventize<TEvents>()) — typed emitters still reject unknown event names at compile time.
Subscribing to Events
on(emitter, ...args)
Subscribes a listener to one or more events and returns an unsubscribe function.
on(ε, eventName(s), [priority], listener, [context]);
on(ε, [priority], listener, [context]); // wildcard subscriptionconst ε = eventize();
const listener = (val) => console.log(val);
const unsubscribe = on(ε, 'my-event', listener);
emit(ε, 'my-event', 'Hello!'); // => "Hello!"
unsubscribe();
emit(ε, 'my-event', 'Silent?'); // (nothing happens)The full set of call shapes — including the method-name form on(ε, 'foo', 'methodName', obj) — is listed in skills/using-eventize/references/api-details.md.
Multiple Event Names
on(ε, ['foo', 'bar'], listener);
emit(ε, 'foo', 1); // => 1
emit(ε, 'bar', 2); // => 2Wildcards (*)
Listen to all events of an object using '*' or by omitting the event name entirely.
const wildcardListener = (...args) => {
console.log('event fired with args:', args);
};
on(ε, '*', wildcardListener); // or just on(ε, wildcardListener)
emit(ε, 'foo', 1, 2); // => event fired with args: [1, 2]
emit(ε, 'bar', 'A'); // => event fired with args: ['A'][!IMPORTANT] A function-form wildcard listener receives only the
emit()arguments. The event name is not passed in. If you need to know which event fired, register a listener-object with an.emit()method instead — eventize falls back to it for events without a matching named method, and passeseventNameas the first argument:on(ε, { emit(eventName, ...args) { console.log(`Event '${eventName}' fired with:`, args); }, }); emit(ε, 'foo', 1, 2); // => Event 'foo' fired with: [1, 2] emit(ε, 'bar', 'A'); // => Event 'bar' fired with: ['A']
[!NOTE] The
.emit()fallback also applies to named subscriptions.on(ε, 'foo', listenerObj)callslistenerObj.emit('foo', ...args)whenlistenerObj.foois not a function. A matching named method always wins over.emit().
Forwarding events between emitters
Because the .emit() fallback matches the signature of the emit method that eventize.inject() (and class extends Eventize) install, you can subscribe one eventized object directly as a catch-all listener of another to forward all events:
const upstream = eventize.inject();
const downstream = eventize.inject();
on(downstream, 'data', (x) => console.log('downstream got', x));
on(upstream, downstream); // forward every event from upstream
emit(upstream, 'data', 42); // => downstream got 42Caveats:
- The target must have an
.emit(eventName, ...args)method.eventize.inject(obj)andclass extends Eventizeinstall one; plaineventize(obj)does not — forwarding to such a target silently does nothing. - A target method whose name matches the event takes precedence over
.emit(). - Forwarding cycles are not detected.
A → B → A(or same-emitter same-event re-emission from inside a listener) recurses without bound and overflows the stack. Eventize threw on this in v4.2, but the guard forbade valid scenarios; breaking cycles is now the caller's job (set a flag, gate the forward, or emit a different event).
Priorities
Listeners with higher priority numbers run first. The default is 0.
import {eventize, on, emit, Priority} from '@spearwolf/eventize';
const ε = eventize();
const calls = [];
on(ε, 'test', () => calls.push('Normal'));
on(ε, 'test', Priority.Low, () => calls.push('Low')); // runs later
on(ε, 'test', Priority.Critical, () => calls.push('Critical')); // runs sooner
emit(ε, 'test');
console.log(calls); // => ["Critical", "Normal", "Low"]Priority provides Max, Critical, High, Normal, Low, and Min. The legacy aliases AAA (= Critical), BB (= High), C, and Default (= Normal) are kept for backwards compatibility.
To give each event of a multi-event subscription its own priority, pass [eventName, priority] tuples. Tuples and bare names may be mixed freely; a tuple's priority overrides the call-level one for that event:
on(ε, [['foo', Priority.Critical], 'bar'], listener);
// 'foo' subscribed at Critical, 'bar' at the default priority
on(ε, [['foo', Priority.Critical], ['bar', Priority.Low]], Priority.High, listener);
// both tuples win over the call-level Priority.HighSince v5.1 this works on typed emitters too, and tuples may be mixed with bare names; event names inside tuples are still checked against the event map. Earlier versions required a homogeneous array of tuples and rejected the form on typed emitters.
Listener Objects
Subscribe an object whose method names match the event names.
const service = {
onSave(data) {
console.log('Saving:', data);
},
onDelete(id) {
console.log('Deleting:', id);
},
};
on(ε, service); // methods are matched to event names
emit(ε, 'onSave', {user: 'test'}); // => "Saving: { user: 'test' }"
emit(ε, 'onDelete', 123); // => "Deleting: 123"Subscribing the same listener-object twice for the same event does not register two listeners — eventize collapses the second call into the existing entry and increments an internal reference count, so the listener still fires once per emit():
const listener = {foo: () => console.log('foo')};
on(ε, 'foo', listener);
on(ε, 'foo', listener); // same (event, priority, listener, context) → refCount = 2
emit(ε, 'foo'); // => "foo" (called once, not twice)[!IMPORTANT] De-duplication applies only to listener-object forms. Plain function listeners are not deduplicated: registering the same function twice produces two independent listeners that will both run. See Reference counting for details.
once(emitter, ...args)
Subscribes a listener that is removed automatically after its first call. Arguments are the same as for on().
once(ε, 'my-event', () => console.log('This runs only once.'));
emit(ε, 'my-event'); // => "This runs only once."
emit(ε, 'my-event'); // (nothing happens)[!NOTE] With multiple event names, the listener is removed after the first of those events fires.
onceAsync(emitter, eventName | eventName[])
Returns a Promise that resolves with the event's first argument.
async function waitForLoad() {
console.log('Waiting for data...');
const data = await onceAsync(ε, 'loaded');
console.log('Data loaded:', data);
}
waitForLoad();
setTimeout(() => emit(ε, 'loaded', {content: '...'}), 100);
// => Waiting for data...
// => Data loaded: { content: '...' }Unsubscribing
off(emitter, ...args)
Removes listeners from an emitter — the counterpart to on(), for cleanup where you no longer hold the unsubscribe function.
off(ε); // all listeners
off(ε, 'foo'); // all listeners for 'foo' (also unretains 'foo')
off(ε, ['foo', 'bar']); // several events
off(ε, listenerFunc); // that function, across all events
off(ε, listenerObject); // every subscription of that object
off(ε, 'foo', listenerObject); // that object, on 'foo' onlyCalling off() on a non-eventized object (or on null/undefined) is a no-op, which makes it safe in cleanup paths without an isEventized() check.
📖 Full off() reference → — every signature, the interaction with retain(), behavior during an active emit(), and reference counting.
Emitting Events
emit(emitter, eventName | eventName[], ...args)
Dispatches an event synchronously, immediately invoking all subscribed listeners.
on(ε, 'update', (id, data) => console.log(`Item ${id}:`, data));
emit(ε, 'update', 42, {status: 'complete'});
// => "Item 42: { status: 'complete' }"
emit(ε, ['update', 'log'], 100, {status: 'multi-event'});[!IMPORTANT]
'*'is reserved for subscribing to all events and cannot be emitted.emit(ε, '*', …)throws — emit a concrete event name instead. (In an array form, events listed before the'*'element still dispatch before the throw, consistent with mid-dispatch error semantics.)Calling
emit()from inside a listener is fine, including re-emitting the same event. Eventize does not detect forwarding cycles or same-event self-recursion —A → B → A(oron(ε, 'foo', () => emit(ε, 'foo'))) will overflow the stack. If you build a forwarding chain, break cycles yourself.
emitAsync(emitter, ...)
Emits an event and returns a Promise that resolves once all promises returned by listeners have resolved. Non-null and non-undefined return values are collected into an array.
on(ε, 'load', () => Promise.resolve('Data from source 1'));
on(ε, 'load', () => 'Simple data');
on(ε, 'load', () => null); // ignored
const results = await emitAsync(ε, 'load');
console.log(results); // => ["Data from source 1", "Simple data"]Arrays are flattened with Promise.all, so a listener returning [1, Promise.resolve(2)] contributes [1, 2].
[!NOTE] When nothing was collected — no listeners, or every listener returned
null/undefined— the promise resolves toundefined, not to an empty array.
Error Handling in Listeners
Listeners are dispatched synchronously. If a listener throws, the exception propagates out of the emit() call (or out of the synchronous portion of emitAsync()) — eventize does not catch it for you.
Consequences worth knowing:
- Dispatch is aborted. Listeners that haven't run yet for the same
emit()will not be called. Listeners that already ran are unaffected. - The throwing listener stays subscribed. It is not auto-removed; the next
emit()calls it again. retain()is not updated for that emit. The retained value is written after all listeners run, so a thrown exception leaves the previously retained value untouched.
const calls = [];
on(ε, 'foo', () => calls.push('first'));
on(ε, 'foo', () => {
throw new Error('boom');
});
on(ε, 'foo', () => calls.push('third')); // not reached
try {
emit(ε, 'foo');
} catch (err) {
console.error(err.message); // => "boom"
}
console.log(calls); // => ["first"]Recommendation: if a single listener's failure should not stop dispatch to the others, wrap that listener's body in try/catch yourself. Eventize deliberately keeps no global error handler so error policy stays explicit at each subscription site.
[!NOTE]
emitAsync()aggregates listener return values into a singlePromise.all. A listener returning a rejected promise rejects the awaited result, but the other listeners — being dispatched synchronously — have already run by then. A listener that throws synchronously still aborts dispatch in the same way as withemit().
State Management
retain(emitter, eventName | eventName[])
Tells an emitter to hold onto the last-emitted event and its data. A new listener is immediately called with the retained data — comparable to a ReplaySubject(1) in RxJS.
import {eventize, retain, emit, on} from '@spearwolf/eventize';
const ε = eventize();
retain(ε, 'status');
emit(ε, 'status', 'ready'); // nobody is listening yet
on(ε, 'status', (currentStatus) => {
console.log(`Status is: ${currentStatus}`);
});
// the new listener fires immediately => "Status is: ready"
emit(ε, 'status', 'running'); // => "Status is: running"Only the last emission is kept, events emitted before the retain() call are not stored, and retain() on a plain object auto-eventizes it.
retainClear(ε, name) discards the stored value but keeps retaining future emissions. unretain(ε, name) drops the value and the policy. Both throw on a non-eventized object.
📖 Full retain reference → — multiple events, symbol names, interaction with once()/onceAsync(), and the exact difference between retainClear and unretain.
Utilities
isEventized(obj)
A type guard returning true if an object has been processed by eventize(). Also available as eventize.is(obj).
import {eventize, isEventized} from '@spearwolf/eventize';
console.log(isEventized(eventize())); // => true
console.log(isEventized({})); // => false
console.log(eventize.is({})); // => falseasEventized(obj)
The low-level primitive behind eventize(obj): attaches the hidden emitter slot and returns the object, without injecting any API methods. Idempotent — an already-eventized object is returned untouched. Reach for eventize() unless you specifically need the primitive.
getSubscriptionCount(emitter)
Returns the number of active subscriptions (named + wildcard listeners). Useful for debugging, testing, or verifying that cleanup actually happened.
const ε = eventize();
on(ε, 'foo', () => {});
on(ε, 'bar', () => {});
on(ε, '*', () => {}); // wildcard listeners are counted too
console.log(getSubscriptionCount(ε)); // => 3
off(ε);
console.log(getSubscriptionCount(ε)); // => 0Edge cases worth knowing:
A non-eventized object returns
0rather than throwing — safe to call on any input.getSubscriptionCount({}); // => 0 getSubscriptionCount(new Date()); // => 0A wildcard listener-object counts as a single subscription, no matter how many event-named methods it exposes — dispatch resolves
listener[eventName]atemit()time.on(ε, {foo() {}, bar() {}, baz() {}}); getSubscriptionCount(ε); // => 1, not 3Subscriptions sharing an entry through reference counting count as one, not as the number of
on()calls.A
once()listener counts as a normal subscription until it fires.
EVENT_CATCH_EM_ALL
The wildcard event name ('*') as a named export, so you don't have to write the magic string.
TypeScript: Typed Event Maps
Eventize ships an opt-in generic event map for eventize<TEvents>(), eventize.inject<TEvents>(), and class extends Eventize<TEvents>. The map describes each event's argument tuple, and the standalone API picks the types up automatically.
import {eventize, emit, on} from '@spearwolf/eventize';
interface ChatEvents {
message: [from: string, text: string];
joined: [user: string];
closed: [];
}
const ε = eventize<ChatEvents>();
on(ε, 'message', (from, text) => {
// from: string, text: string — inferred from the map
});
emit(ε, 'message', 'alice', 'hello'); // ✅
// emit(ε, 'unknown', 1); // ❌ unknown event name
// emit(ε, 'message', 'alice'); // ❌ missing 'text'Define the map as a plain interface — do not extends EventMap, which would inherit an index signature and widen keyof back to string | symbol.
Without a generic, every API behaves exactly like v4.0.x: arbitrary event names, arbitrary arguments, listener-objects with whatever method names you like.
📖 Full typed-events reference → — typed listener-objects, the inject and class forms, symbol events as an escape hatch, and the caveats around off() and multi-event calls.
