npm.io
6.2.0 • Published 2 weeks ago

@spearwolf/eventize

Licence
Apache-2.0
Version
6.2.0
Deps
0
Size
1.3 MB
Vulns
0
Weekly
0
Stars
3

@spearwolf/eventize

A tiny, clever, and dependency-free library for synchronous event-driven programming in JavaScript and TypeScript.

npm (scoped) GitHub Workflow Status (with event) GitHub

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 (~6.5 kB gzipped, practically indivisible — see above).
  • ESM & CommonJS Support.
  • Apache 2.0 Licensed.

Installation

$ npm install @spearwolf/eventize

The library is distributed in both ES Module (import) and CommonJS (require) formats.

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
lifecycle.md what an emitter holds, what each off() form releases, handle lifetime
typed-events.md generic event maps, the EventMap trap, symbol escape hatch
migration.md v5 → v6 breaking changes, the v4 → v5 emit change, the v4.3 type-brand migration for classes

The skill folder is self-contained: everything it references lives inside it, so it works when symlinked out of the repo.

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-eventize

Skills are auto-discovered — no extra registration step.

Further documentation

The deep material behind the summaries below:

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.

Emitter emits named event to listeners

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 42

The 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.

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 emitter
Listeners

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
emitSafe dispatch an event; a throwing listener does not stop the others
emitSafeAsync the async version of emitSafe
emitStrict dispatch an event; every listener runs and every failure is raised
emitStrictAsync the async version of emitStrict
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"

The target must be extensible: eventize(Object.freeze(obj)) throws a TypeError naming the cause — frozen, sealed and preventExtensions()ed alike. An object frozen after it was eventized keeps working.

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+), emitSafe(), emitSafeAsync() (v6.1.0), emitStrict(), emitStrictAsync() (v6.2.0) Duck-types: calls obj[eventName](...args) — a function target too (v6.0.0)
off(), getSubscriptionCount(), getSubscribedEventNames(), getRetainedCount(), getRetainedEventNames() Silently does nothing / returns 0 / []
retainClear(), unretain() Throws TypeError

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:

  1. If obj[eventName] is a function the object actually provides → call it with the args (with this === obj).
  2. Else if obj.emit is a function → call obj.emit(eventName, ...args).
  3. Otherwise → silently no-op.

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)

// ✅ Duck-typing (v6.0.0): a function or a class is a target too
class Registry {
  static reset(reason) {
    console.log('reset:', reason);
  }
}
emit(Registry, 'reset', 'shutdown'); // => "reset: shutdown"
emit(Registry, 'bind', globalThis); // no-op — inherited from Function.prototype

// ✅ off() is permissive — safe in cleanup paths
off({}); // no-op, no throw

// ❌ Strict: retain-state mutators still surface typos
retainClear({}, 'foo'); // throws TypeError: retainClear() cannot operate on a non-eventized object — ...
unretain({}, 'foo'); // throws TypeError: unretain() cannot operate on a non-eventized object — ...

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 with isEventized() or use a typed emitter (eventize<TEvents>()) — typed emitters still reject unknown event names at compile time. See the migration guide.


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 subscription
const ε = 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.

The listener slot takes only what can be dispatched to: a function, a method name (string or symbol), or a listener object. Anything else throws — on(ε, 'foo', 5) fails instead of registering a subscription no emit() could ever reach.

A method name is resolved off its listener object at dispatch time, so the method may appear later — that is late binding, and it still works.

Multiple Event Names
on(ε, ['foo', 'bar'], listener);

emit(ε, 'foo', 1); // => 1
emit(ε, 'bar', 2); // => 2

An empty array throws (since v6.0.0). Where the name list is assembled at runtime, check its .length before subscribing.

Every entry has to be an event name — a string or a symbol (since v6.0.0).

on(ε, [123], listener) used to file a bucket under 123 that no emit() could reach and no off(ε, 123) could remove; the same for null, undefined and a nested array. Filter a runtime-assembled list, or check the first slot of each [name, priority] tuple.

A single name is checked the same way wherever a priority follows it — on(ε, {}, 10, listener) throws too.

Wildcards (*)

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']

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 passes eventName as 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']

The .emit() fallback also applies to named subscriptions. on(ε, 'foo', listenerObj) calls listenerObj.emit('foo', ...args) when listenerObj.foo is 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 42

Caveats:

  • The target must have an .emit(eventName, ...args) method. eventize.inject(obj) and class extends Eventize install one; plain eventize(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, Medium, Normal, Low, and Min. The legacy aliases AAA (= Critical), BB (= High), C (= Medium), and Default (= Normal) are @deprecated on EventizePriority and slated for removal in a future major — they keep working, but editors now strike them through.

A priority must be an actual number: since v6.0.0 NaN throws (subscribeTo() called with a NaN priority), in every position a priority can occupy — up to v5.1.0 it passed and left the listener wherever the size of the bucket put it.

Priority.Max and Priority.Min are ±Infinity and are perfectly valid — this is not a finiteness test.

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.High

Since 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.

The NaN rule reaches into the tuples, and it rejects the whole call: a single NaN in one tuple leaves none of the listed names subscribed, and a NaN at call level throws even when every tuple carries its own priority to override it.

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 with on() 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)

De-duplication applies only to listener-object forms — and, since v6.0.0, once() shares it with on(): subscribing the same listener object through any mixture of the two calls, in any order, still yields one registration. 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)

With multiple event names, the listener is removed after the first of those events fires.

once() aggregates onto the same listener-object identity on() does (since v6.0.0). Two once(ε, 'foo', listenerObject) calls land on one listener: the next emit() calls it once and discharges both. An on(ε, 'foo', listenerObject) on the same identity keeps it subscribed after that. See Reference counting.

onceAsync(emitter, eventName | eventName[], options?)

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: '...' }

onceAsync() takes an optional {signal}. Without it, an event that never fires keeps the listener, the resolve closure and the caller's await continuation attached to the emitter for as long as the emitter lives — there is no other handle to release them with.

const controller = new AbortController();
try {
  const value = await onceAsync(ε, 'ready', {signal: controller.signal});
} catch (err) {
  if (err.name === 'AbortError') {
    /* cancelled */
  }
}
// somewhere in teardown:
controller.abort();

Aborting unsubscribes the internal once() and rejects with the signal's reason whenever it has one, and with an AbortError DOMException otherwise — including the abort(null) case, where fetch() would hand you a null to catch. A signal that is already aborted rejects without ever subscribing.


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 — and all retained state
off(ε, undefined);            // ⚠️ the same branch — wipes everything
off(ε, 'foo');                // all listeners for 'foo' (also unretains 'foo')
off(ε, ['foo', 'bar']);       // several events
off(ε, listenerFunc);         // that function, under every context it was registered with
off(ε, listenerFunc, ctx);    // only where it was registered with exactly that context
off(ε, listenerObject);       // every subscription of that object
off(ε, 'foo', listenerObject); // that object, on 'foo' only — but still unretains 'foo'
off(ε, '*', listenerObject);   // that object's wildcard subscription only

A nullish second argument is not a no-op. off(ε, handlers[name]) empties the whole emitter when the lookup misses, and one null or undefined element turns an array form into the same total wipe. Guard the lookup, or keep the handle on() returned and call that.

Calling off() on a non-eventized object (or on null/undefined as the emitter) is a no-op, which makes it safe in cleanup paths without an isEventized() check — safe against anything except an object eventized by an incompatible copy of the library, which throws the same protocol TypeError every other call does.

Since v6.0.0 the bulk forms off(ε) and off(ε, '*') also empty the retained-events keeper — every retained value and every retain policy goes with the listeners. Before that they cleared only the listeners, so a subscriber arriving afterwards was still handed the old payload.

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'});

'*' 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 (or on(ε, '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"]

A listener returning an array has its elements awaited with Promise.all, but the array stays one entry of the result: a lone listener returning [1, Promise.resolve(2)] gives [[1, 2]], not [1, 2]. Flatten yourself if you want the elements inline.

When nothing was collected — no listeners, or every listener returned null/undefined — the promise resolves to undefined, 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"]

Three ways out. Since v6.1.0, emitSafe() and emitSafeAsync() dispatch the same event with each listener isolated: a throw is reported through console.warn and the listeners behind it still run.

const calls = [];

on(ε, 'foo', () => calls.push('first'));
on(ε, 'foo', () => {
  throw new Error('boom');
});
on(ε, 'foo', () => calls.push('third'));

emitSafe(ε, 'foo'); // no throw; console.warn reports the failure

console.log(calls); // => ["first", "third"]

What they guarantee is execution, not completeness: no listener can prevent the others from running. They do not promise that nothing went wrong, they hand you no error object, and emitSafeAsync() still rejects if a listener returns a rejected promise — by then every listener has already run. emit(ε, '*') still throws from every dispatch function. The third bullet above flips with them: nothing unwinds, so the retained value is written.

Two behaviours differ from emit(), both on purpose: the retained value is written, because the event was delivered; and a once() queued behind a throwing listener is spent, because it now runs. The throwing listener itself keeps its subscription either way.

When you need both halves, emitStrict() and emitStrictAsync() (v6.2.0) run every listener and then raise what failed:

on(ε, 'foo', () => calls.push('first'));
on(ε, 'foo', () => {
  throw new Error('boom');
});
on(ε, 'foo', () => calls.push('third'));

emitStrict(ε, 'foo'); // throws "boom" — after both other listeners ran

One failure is rethrown unchanged, so an existing toThrow('boom') assertion survives the swap; two or more arrive as an AggregateError in dispatch order, which is a shape emit() could never produce. emitStrictAsync() collects rejected listener promises the same way and reports everything through its promise — it never throws synchronously, not even for '*'. docs/emit.md has the rest, the cost included.

The third way out is unchanged and still the right one where a single listener needs its own policy: wrap that listener's body in try/catch. Eventize deliberately keeps no global error handler, so emit() stays the default and error policy stays explicit at the call site.

See docs/emit.md for the full dispatch semantics.

emitAsync() aggregates listener return values into a single Promise.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 with emit().

Mixing the two in one dispatch costs the collected values. The aggregation is built after the dispatch returns, so a synchronous throw — from a listener, or from '*' inside a name array — never reaches it: the caller gets the throw and nothing else. Every value collected up to that point is dropped, and any rejection among them is discarded along with it, so no unhandledrejection fires and Node's default --unhandled-rejections=throw has nothing to terminate on. What a listener already returned is unrecoverable, though — wrap a throwing listener in try/catch if the values before it matter.


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). On a typed emitter it preserves the event map it narrows, so a wrong event name stays a compile error inside the if — up to v5.1.0 the narrowing widened the map back to the permissive default and silently reopened every loose overload.

The marker it probes for is a property, so it is inherited like any other: eventize(SomeClass.prototype) makes every instance answer true and share that one prototype's emitter — on() on one instance is reachable from emit() on another. Useful for a single emitter shared by a whole class, surprising when each instance was expected to keep independent subscriptions.

import {eventize, isEventized} from '@spearwolf/eventize';

console.log(isEventized(eventize())); // => true
console.log(isEventized({}));         // => false
console.log(eventize.is({}));         // => false
asEventized(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, unless another copy of the library eventized it, in which case it throws the protocol-mismatch TypeError rather than handing back an emitter it cannot drive. Like eventize(), it refuses a non-extensible target. Reach for eventize() unless you specifically need the primitive.

getEventizeProtocol(obj)

Which copy of eventize eventized this object. The marker is keyed by Symbol.for('eventize') — realm-wide, so two majors installed side by side share one slot and each reads the other's payload as its own. Since v6.0.0 the payload carries a protocol number, and this is how you read it.

import {eventize, getEventizeProtocol, isEventized} from '@spearwolf/eventize';

console.log(getEventizeProtocol(eventize())); // => 6
console.log(getEventizeProtocol({}));         // => undefined
console.log(getEventizeProtocol(null));       // => undefined

It never throws — it is the tool for diagnosing the situation before something else does. Two kinds of undefined come back, and isEventized() separates them: false means the object was never eventized, true means a copy from before the field existed (up to v5.1.0) got there first.

Any other number means two incompatible copies are live on the same object, and every on() / emit() / off() against it throws a TypeError naming both protocols and the remedy — dedupe @spearwolf/eventize in your dependency tree. See the migration guide.

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(ε)); // => 0

Edge cases worth knowing:

  • A non-eventized object returns 0 rather than throwing — safe to call on any input.

    getSubscriptionCount({}); // => 0
    getSubscriptionCount(new Date()); // => 0
  • A wildcard listener-object counts as a single subscription, no matter how many event-named methods it exposes — dispatch resolves listener[eventName] at emit() time.

    on(ε, {foo() {}, bar() {}, baz() {}});
    getSubscriptionCount(ε); // => 1, not 3
  • Subscriptions 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.

Inspecting emitter state
  • getSubscriptionCount(ε) — how many listeners are registered.
  • getSubscribedEventNames(ε) — every event name with an active listener, named plus EVENT_CATCH_EM_ALL if a wildcard listener is registered, in unspecified order. getSubscribedEventNames(ε).length === 0 agrees with getSubscriptionCount(ε) === 0 exactly, and says which names when it doesn't.
  • getRetainedCount(ε) — how many events hold a retained value.
  • getRetainedEventNames(ε) — every name carrying a retain policy, fired or not.
  • getEventizeProtocol(ε) — which copy of eventize owns the marker.

They exist for debugging, testing, and verifying that cleanup actually happened.

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 all three surfaces pick the types up automatically — the standalone functions since v4.1, the injected methods and the class since v6.0.0.

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. extends EventMap buys nothing — EventMap is object, so nothing is inherited and the narrowing survives — but it costs an import for no effect. What actually reopens the map is an index signature written into it ([key: string]: any[]), which is the deliberate escape hatch for dynamic names.

Every value has to be an argument tuple — [] for an event carrying none; a readonly tuple and an optional key are both fine and both checked positionally. A value that is not an array makes emit(ε, 'name', …), on(ε, 'name', fn) and the typed listener-object form compile errors for that key since v6.

Without a generic, every API behaves exactly like v4.0.x: arbitrary event names, arbitrary arguments, listener-objects with whatever method names you like.

Exported types

Everything below is exported from the package root, and each exists to be written by a consumer rather than merely inferred:

Type What it is for
EventName string | symbol — what an event may be called, and the element type getSubscribedEventNames() / getRetainedEventNames() hand back
EventMap, DefaultEventMap the constraint on an event map, and the permissive default
EventizedObject<TEvents> the branded emitter type. A class that calls eventize(this) declaration-merges with it — export interface MyClass extends EventizedObject {} — so that emit(this, …) inside the class type-checks; see the migration guide. Extend this, never EventizeApi, which carries the method signatures and collides with same-named methods on the host class
UnsubscribeFunc what on() and once() hand back — () => void, nothing else
OnceAsyncOptions the {signal?: AbortSignal} bag onceAsync() takes
EventNameWithPriority the [eventName, priority] tuple the array form of on() accepts
StandaloneSubscribeFunc the exported overload set of the standalone on() / once(), for a wrapper that needs to match it exactly rather than widen to SubscribeImpl
ListenerObjectSlot<L> the listener-object constraint that rejects an array, a function and nullish — appears in the published on() / once() signatures
MultiArgsFor<T, K> the merged argument list for a listener serving several event keys at once
AnyEventNames the event-name argument of emit() / retain(): a single name or an array of names, with no notion of priority
EventArgs any[], the loose argument-list type a subclass override of emit() forwards through untyped
SubscribeImpl the implementation signature to cast to when writing a forwarding wrapper around on() / once()
SubscribeArgs the union of every accepted argument shape, plus eleven named arms so a wrapper can name the one it handles

The last two are the ones worth knowing about: TypeScript refuses to spread a union of tuples into a fixed-arity call, so on(target, ...args) never compiles against the public overloads. const rawOn = on as SubscribeImpl is the sanctioned escape — see docs/typed-events.md → Wrapping on() / once().

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.