npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ts-signal

v0.1.2

Published

Typescript-safe signals with utility functions

Downloads

730

Readme

ts-signal

Typescript-safe signals with minimal footprint, AbortController support, and advanced utility functions.

A type-safe event-emitter replacement with simple subscriptions, Promisified waitFor, type-guard filter, and stateful signals.

npm license minified NPM Downloads

Features

  • Fully Type-Safe: Payloads and filters maintain exact type inference.
  • Lightweight: Minimal API surface, zero dependencies.
  • AbortController Support: Native support for memory-leak-free unsubscription.
  • Promise Integrated: Wait for the next emission with timeouts out-of-the-box.
  • Stateful Signals: Built-in support for values that persist across events.
  • Automatic Memory Cleanup: Zero-leak derived signals (filter, pipe, toStateful) via lazy evaluation and automatic lifecycle management.

Installation

npm install ts-signal

Or using yarn, pnpm, or bun:

pnpm add ts-signal

Quick Start

import { Signal } from "ts-signal";

const onUserLogin = new Signal<{ id: string; name: string }>();

const unsubscribe = onUserLogin.attach((user) => {
  console.log(`Welcome, ${user.name}!`);
});

onUserLogin.post({ id: "123", name: "Alice" });

unsubscribe();

Requirements

  • Node.js: >= 20 (Built for modern active LTS environments).
  • Module: ESM Only (import / export). CommonJS require() is not supported.

API Documentation

Signal<T>

The core event dispatcher. If the payload is empty, use Signal<void>.

setMaxHandlers(count: number): void

Sets the maximum number of attached listeners before emitting a console warning. Useful for detecting memory leaks. Defaults to 20.

attach(handler: (payload: T) => void, signal?: AbortSignal): () => void

Registers a callback to be invoked whenever the signal emits. Returns an unsubscribe function.

const onData = new Signal<string>();

const unsub = onData.attach((data) => console.log(data));
onData.post("Hello!"); // Logs: "Hello!"

unsub();
onData.post("World!"); // Nothing logged

Pass an AbortSignal for automatic cleanup:

const ac = new AbortController();
const onData = new Signal<string>();

onData.attach((data) => console.log(data), ac.signal);
ac.abort(); // Automatically unsubscribes

attachOnce(handler: (payload: T) => void, signal?: AbortSignal): () => void

Registers a callback similar to attach, but it automatically unsubscribes itself immediately after the very first execution.

const onReady = new Signal<void>();

onReady.attachOnce(() => console.log("Ready!"));
onReady.post(); // Logs: "Ready!"
onReady.post(); // Nothing is logged

waitFor(timeout?: number, signal?: AbortSignal): Promise<T>

Returns a Promise that resolves with the next emitted payload.

  • Throws a SignalTimeoutError if timeout in ms is provided and elapsed.
  • Throws a SignalAbortError if the AbortSignal is aborted.
const onReady = new Signal<void>();

try {
  await onReady.waitFor(5000); // Continues if resolved within 5 seconds
} catch (e) {
  if (e instanceof SignalTimeoutError) {
    console.error("Operation timed out!");
  }
}

detach(handler?: (payload: T) => void): void

Removes a specific handler. If no handler is provided, clears all attached handlers. On derived signals (filter, pipe, toStateful), this also tears down the parent subscription when handlers drop to zero.

post(payload: T): void

Emits the payload synchronously to all currently attached handlers. Safe to call even if handlers attach or detach other handlers during emission.

filter(predicate)

Returns a new derived Signal that only emits values matching the predicate.

Supports TypeScript Type Guards to automatically narrow the payload type.

const incoming = new Signal<string | number>();

// Type completely narrowed to Signal<string>
const stringOnly = incoming.filter((p): p is string => typeof p === "string");

stringOnly.attach((str) => {
  console.log(str.toUpperCase()); // Safe to use string methods here!
});

With discriminated unions, TypeScript narrows the type automatically — no type guard needed:

type Event =
  | { type: "message"; text: string }
  | { type: "error"; code: number };

const events = new Signal<Event>();

const errors = events.filter((e) => e.type === "error");
errors.attach((e) => console.log(e.code)); // `e` is narrowed, `code` is available

pipe(...fns)

Returns a new derived Signal by piping the emitted values through one or more transform functions. Strongly typed for up to 9 functions.

const signal = new Signal<{ type: "message"; message: string }>();

// messages is inferred as Signal<string>
const messages = signal.pipe((event) => event.message);

messages.attach((msg) => console.log(msg));
signal.post({ type: "message", message: "Hello!" }); // Logs: "Hello!"

Chaining multiple transforms:

const signal = new Signal<number>();

const labels = signal.pipe(
  (n) => n * 2,
  (n) => `Value: ${n}`,
);

labels.attach(console.log);
signal.post(5); // Logs: "Value: 10"
signal.post(15); // Logs: "Value: 30"

toStateful(initialState: T): StatefulSignal<T>

Returns a new StatefulSignal derived from this signal. The stateful signal only subscribes to the parent while it has active handlers, and automatically unsubscribes when the last handler detaches.


StatefulSignal<T>

Extends Signal<T> but persists the last emitted value.

constructor(initialState: T)

Creates a new StatefulSignal with the given initial state.

state: T (Getter)

Synchronously accessible current state property.

attach(handler: (payload: T) => void, signal?: AbortSignal): () => void

Attaches a handler and immediately invokes it with the current .state unless the passed AbortSignal is already aborted. Continues to trigger on further updates exactly like a standard Signal.

const userScore = new StatefulSignal<number>(0);

userScore.attach((score) => console.log(`Current Score: ${score}`));
// Immediately logs "Current Score: 0"

userScore.post(10);
// Logs "Current Score: 10"

waitFor(timeout?: number, signal?: AbortSignal): Promise<T>

Like Signal.waitFor, but resolves immediately with the current state. Since it resolves immediately, the timeout has no effect.


Utility Types

SignalType<S>

A type helper to extract the internal payload type of a given signal.

import type { SignalType } from "ts-signal";

type MySignal = Signal<string>;
type Payload = SignalType<MySignal>; // Evaluates to `string`

Advanced Use Cases

Automated Lifecycle Management

By binding listeners with AbortController, complex setups and teardowns are centralized and inherently leak-proof:

class ReactiveComponent {
  private abortController = new AbortController();

  mount(globalSignal: Signal<number>) {
    globalSignal.attach((val) => console.log(val), this.abortController.signal);
  }

  unmount() {
    // Unsubscribes from all signals attached with this controller
    this.abortController.abort();
  }
}

Safe Derived Signals (Cold Signals)

Derived signals created by filter, pipe, and toStateful are cold — they only subscribe to the parent signal while they have active handlers, and automatically unsubscribe when the last handler detaches. Once unsubscribed, the derived signal becomes eligible for garbage collection.

This means that doing:

await incoming.filter((x) => x.type === "message").waitFor();

Is memory-leak free out of the box because:

  1. The derived signal only subscribes to the parent when .waitFor() internally calls .attachOnce().
  2. When the promise resolves, .attachOnce() drops the listener count to 0.
  3. The derived signal detects this, unsubscribes from the parent signal, and becomes eligible for garbage collection.