ts-signals
v0.1.0
Published
A typed signal/event system for TypeScript
Readme
ts-signals
A lightweight, strongly-typed signal / event system for TypeScript with mandatory context binding, one-time handlers, and grouped removal by owner.
Designed for OOP-style lifecycle-driven code, not for reactive streams or global event buses.
Installation
npm install ts-signalsWhy ts-signals?
ts-signals exists to solve a specific problem:
Managing event subscriptions in object-oriented code without losing track of ownership, lifecycle, or cleanup.
Intended use cases
- Game engines (PixiJS, Phaser, custom engines)
- UI systems with explicit object lifecycles
- Entity / component architectures
- Systems where objects own their subscriptions
Explicit non-goals
This library is not intended to replace:
- RxJS (no streams, operators, async composition)
- Node.js EventEmitter (no string-based events)
- Global pub/sub or message buses
- React-style functional event handling
If you need functional, stateless, or reactive patterns — use a different tool.
Core design principles
- Context is mandatory
- Every handler is bound to an owner object
- The same context is used as this and as a lifecycle key
- Ownership over convenience
- Subscriptions are grouped by owner, not anonymous callbacks
- Synchronous and predictable
- Handlers are executed synchronously, in subscription order
- Explicit cleanup
- No automatic garbage collection of handlers
- You must remove handlers or contexts explicitly
This is a deliberate trade-off in favor of clarity and control.
Basic usage
import { Signal } from 'ts-signals';
class GameScene {
onScore = new Signal<number>();
setup(player: Player) {
this.onScore.add(player.onScoreChanged, player);
}
teardown(player: Player) {
// Remove one specific handler
this.onScore.remove(player.onScoreChanged, player);
// Or remove all handlers registered by player at once
this.onScore.removeContext(player);
}
}One-time handler
const onReady = new Signal<void>();
onReady.addOnce(scene.init, scene);
onReady.emit(); // scene.init fires once, then removed
onReady.emit(); // no outputUnsubscribe via returned function
const off = signal.add(player.onDamage, player);
// later...
off(); // equivalent to signal.remove(player.onDamage, player)Using the handler type
import { Signal, SignalHandler } from 'ts-signals';
const onScore = new Signal<number>();
const handler: SignalHandler<number> = function (this: Player, score: number) {
console.log(`${this.name} scored: ${score}`);
};
onScore.add(handler, player);
onScore.emit(42);
onScore.remove(handler, player);API
new Signal<T>()
Creates a new signal.
- T — type of data passed to handlers
- Defaults to void
signal.add(handler, context): () => void
Subscribe a handler.
- handler — (data: T) => void
- context — owner object
Used as:
- this binding when calling the handler
- grouping key for removeContext()
- Returns an unsubscribe function
Throws if handler is not a function.
signal.addOnce(handler, context): () => void
Subscribe a handler that fires only once and is then removed.
Throws if handler is not a function.
signal.emit(data: T): void
Emit the signal.
- Regular handlers fire first, followed by one-time handlers
- Handlers are executed synchronously
- Execution order:
- insertion order per context
- no guaranteed ordering across different contexts
- Exceptions are not caught internally
signal.remove(handler, context): void
Remove a specific handler. The same context must be provided.
signal.removeContext(context): void
Remove all handlers registered under a context.
This is the primary cleanup mechanism and should be called when an object is destroyed.
signal.removeAll(): void
Remove all handlers (regular and one-time).
signal.has(handler, context): boolean
Returns true if the given handler is currently subscribed under the given context (regular or one-time).
signal.add(player.onDamage, player);
signal.has(player.onDamage, player); // true
signal.remove(player.onDamage, player);
signal.has(player.onDamage, player); // falsesignal.hasContext(context): boolean
Returns true if there are any handlers (regular or one-time) registered under the given context.
signal.add(player.onDamage, player);
signal.hasContext(player); // true
signal.removeContext(player);
signal.hasContext(player); // falsesignal.contexts(): Iterable<object>
Returns an iterable of all unique context objects that currently have at least one registered handler. The returned snapshot is not live.
signal.add(player.onDamage, player);
signal.add(enemy.onDamage, enemy);
for (const ctx of signal.contexts()) { ... } // player, enemysignal.listenerCount(): number
Returns the total number of active handlers (regular + one-time). Useful for debugging and leak detection.
signal.listenerCountFor(context): number
Returns the number of handlers (regular + one-time) registered under a specific context.
signal.add(player.onDamage, player);
signal.addOnce(player.onReady, player);
signal.listenerCountFor(player); // 2Memory & lifecycle model
ts-signals uses strong references to contexts. This enables:
- explicit ownership
- predictable cleanup
- debugging and introspection
As a result, handlers are not automatically garbage-collected.
Objects that subscribe to signals are expected to explicitly remove their handlers or call removeContext() as part of their lifecycle.
This mirrors the behavior of DOM events and other ownership-based systems and is a deliberate trade-off in favor of control and debuggability.
License
MIT
