# eventize — typed event maps Opt-in since v4.1. Pass a generic to `eventize()`, `eventize.inject()`, or `class extends Eventize`; all three surfaces pick the types up automatically — the standalone functions since v4.1, the injected methods and the class since v6.0.0. ```ts interface ChatEvents { message: [from: string, text: string]; joined: [user: string]; closed: []; } const ε = eventize(); on(ε, 'message', (from, text) => {/* from: string, text: string */}); emit(ε, 'message', 'alice', 'hello'); // ✅ // emit(ε, 'unknown', 1); // ❌ unknown event name // emit(ε, 'message', 'alice'); // ❌ missing 'text' const first = await onceAsync(ε, 'message'); // string — the tuple's first element ``` Listener-objects are checked per method, and unknown keys are rejected: ```ts on(ε, { message(from, text) {/* typed */}, joined(user) {/* typed */}, // banana() {} // ❌ not in the map }); ``` ## The inject and class forms ```ts const ε = eventize.inject(); ε.emit('joined', 'carol'); // ✅ typed ε.on('message', (from, text) => {/*…*/}); // ✅ typed // ε.emit('joind', 'carol'); // ❌ typo — rejected since v6.0.0 class Chat extends Eventize { greet(user: string) { this.emit('joined', user); // ✅ typed // this.emit('joind', user); // ❌ typo — rejected since v6.0.0 } } ``` Since v6.0.0 both method forms reject an event name the map does not declare and an argument tuple that does not match it. Up to v5.1.0 both accepted them: the guard that closes the loose overloads sat on the standalone functions' `obj` parameter, and a method has none. It sits on the event-name slot now. The class needed a second fix — it declared its own `on` / `emit` / … in the class body, and a member declared there wins over the same name inherited from the merged interface, so the loose implementation signature was the public one however well the interface was tuned. The implementations sit on the prototype instead, which leaves the merged interface as the class's only type source. The two guards sit in different slots. On the method surfaces it is the event-name slot, so a call with no event name to check — a catch-all, or a listener-object alone — stays open; a listener-object passed _with_ an event name has its name checked and its method names not. The standalone functions guard `obj`, which closes the loose set wholesale for a typed emitter, name-free forms included. Since v6.0.0 the shapes that cost them are mirrored back, so all three surfaces take the same call forms: `on(ε, 'message', obj)`, `on(ε, 'message', 'method', obj)`, `on(ε, 'message', fn, thisArg)` and `on(ε, fn)`, each with its priority variant, and the same for `once()`. The event name is checked wherever one is given, but how much _after_ it stays checked differs by shape. The listener-object and method-name forms leave everything after the name loose, because the method is resolved at dispatch and is not required to exist. The function-plus-context form checks the listener too — it is the same typed listener the two-argument `on(ε, 'message', fn)` takes, so `on(ε, 'message', (from: number) => …, thisArg)` is still a compile error against `message: [from: string, text: string]`; only the trailing context is loose. That form also needed adding to `ε.on()` and `this.on()`: it compiled on no surface, and its trailing context is the fourth slot of the dedup tuple that `off(ε, fn, thisArg)` removes by. One split is deliberate and stays: the `banana()` above is a compile error on the standalone two-argument `on(ε, listenerObject)`, whose method names are checked against the map, and an accepted, unreachable subscription on `ε.on()` and `this.on()` alike. Give the object an event name and the name is checked everywhere. If you want a typed map _and_ dynamic names, say so in the map: ```ts interface ChatEvents { message: [from: string, text: string]; [key: string]: any[]; // dynamic names stay open } ``` ## Define the map as a plain interface ```ts // ✅ keyof stays narrow: 'foo' | 'bar' interface MyEvents { foo: [string]; bar: []; } // ❌ pointless, not dangerous — `EventMap` is `object`, so nothing is // inherited: keyof stays 'foo' and every narrowing survives. The heritage // clause and its import buy nothing. What *does* reopen the map is an index // signature written into it — see "Symbols are an escape hatch" below. interface MyEventsBad extends EventMap { foo: [string]; } ``` The constraint on `TEvents` is deliberately as loose as `object` so a plain interface satisfies it without an index signature. That looseness is the price of strict narrowing. Every value has to be an argument tuple — `[]` for an event carrying none. A `readonly` tuple and an optional key both work and are checked positionally. Anything that is not an array breaks the convention and, since v6.0.0, fails at the `emit()` *and* the `on()` call site rather than at the declaration; up to v5.1.0 all three fell back to `any[]`, so the key its author got wrong was the one key nothing checked. ```ts interface MyEvents { message: string; // ❌ not a tuple — see below for what fails joined: readonly [user: string]; // ✅ checked positionally left?: [user: string]; // ✅ optional, checked positionally closed: []; // ✅ an event with no arguments } ``` One rule says where a broken key surfaces: it fails wherever an argument list is checked, and passes through wherever none is. It fails at `emit(ε, 'message', …)` (the empty call included), `on(ε, 'message', fn)`, the array form `on(ε, ['message'], fn)` and the typed listener-object `on(ε, {message() {}})`. It passes through `on(ε, 'message', 'handler', obj)` and `on(ε, 'message', obj)`, which check the name and resolve the rest at dispatch; through `emit(ε, ['message', 'joined'], 'bob')`, where the union of the listed tuples absorbs the `never` (the multi-event rule in the caveats below, seen from its other side); and through `onceAsync(ε, 'message')`, which resolves `Promise` — the same as an undeclared symbol, so that call cannot tell the two apart. ## Symbols are an escape hatch Symbol event names are accepted on a typed emitter even when absent from the map, with permissive arguments — useful for private events alongside a typed public surface: ```ts const PRIVATE = Symbol('private'); const ε = eventize(); on(ε, PRIVATE, (...args) => {}); // ✅ emit(ε, PRIVATE, 'anything'); // ✅ permissive retain(ε, PRIVATE); // ✅ the retain family too await onceAsync(ε, PRIVATE); // ✅ resolves `void` ``` Since v6.0.0 `onceAsync`, `retain`, `retainClear`, `unretain` and the array arm of `emit` / `emitAsync` honour it too, on all three surfaces — up to v5.1.0 those four took declared keys only, so a private symbol event could be subscribed and fired but neither retained nor awaited. Two edges remain, both deliberate: - The **array arm of `on()` / `once()`** takes declared names only: `on(ε, [PRIVATE], fn)` is a compile error, `on(ε, PRIVATE, fn)` is not. The multi-name form merges the listed tuples into one listener signature and an undeclared name brings none. - **`onceAsync(ε, PRIVATE)` resolves to `void`** — an undeclared event has no first tuple element to name. The value arrives at runtime; `onceAsync(ε, PRIVATE)` is the spelling that types it (standalone only — a typed map closes the loose arm the method surfaces would need), while `const v: string = await onceAsync(ε, PRIVATE)` is a `TS2322`. Add the symbol to the map (`[PRIVATE]: [reason: string]`) if you want it checked. ## Caveats - Multi-event `emit(ε, ['a', 'b'], …)` is checked against the **union** of the listed tuples, not a shared one: it compiles as soon as the arguments match at least one listed name, and the runtime then hands the same arguments to every name. `emit(ε, ['message', 'joined'], 'alice')` compiles and dispatches `message` one argument short. Use separate calls when the tuples differ — the compiler will not stop you. Sharpest edge of the same rule: an undeclared symbol in the list contributes `any[]` to that union, so `emit(ε, [PRIVATE, 'message'], 1)` checks nothing at all, the declared name included. - Multi-event `on()` / `once()` does **not**: since v6.0.0 a common listener compiles even when the tuples differ. Identical tuples keep it positionally typed; differing ones give each parameter the union of all element types, since positional information does not exist for one function serving two shapes. - Per-event priority tuples (`on(ε, [['a', Priority.High], 'b'], fn)`) work on typed emitters, and names inside tuples are still checked against the map — see `api-details.md`. - `off()` is intentionally **untyped** against `TEvents` — every parameter accepts `unknown`. Cleanup code routinely handles values of unknown origin, and the runtime is permissive anyway. - `getSubscriptionCount()` and `EVENT_CATCH_EM_ALL` are diagnostic or structural and carry no event-map typing. `isEventized()` used to be in that group and no longer is: since v6.0.0 it preserves the map of the emitter it narrows, so a typed `emit()` inside the `if` is checked exactly as it is outside. - The `__TEventsBrand` phantom field on `EventizedObject` exists only at compile time; the symbol isn't exported, so user code can't mismatch it. - Without a generic, everything falls back to the fully permissive v4 signatures: arbitrary event names, arbitrary args, listener-objects with any method names.