Skip to content

@studnicky/signal

Compose AbortSignals from callers and deadlines without repetitive AbortController boilerplate.

Install

bash
pnpm add @studnicky/signal

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

Compose a signal from a caller signal and/or a deadline

Signal.compose combines a caller AbortSignal and a timeout deadline using AbortSignal.any. The composed signal aborts as soon as either source fires. If neither is provided, it returns the never-aborting sentinel so consuming code always receives a valid AbortSignal.

ts
import { Signal, SignalError } from '../src/index.js';

class ComposeDemo {
  /** Case 1: both caller signal + deadlineMs — returns AbortSignal.any composite. */
  static async caseCallerAndDeadline(): Promise<void> {
    const controller = new AbortController();
    const signal = await Signal.compose({ 'deadlineMs': 5000, 'signal': controller.signal });

    assert.ok(signal instanceof AbortSignal, 'composite is an AbortSignal');
    assert.ok(!signal.aborted, 'composite is not yet aborted');
    console.log(`caseCallerAndDeadline: aborted=${signal.aborted}`);
  }

  /** Case 2: caller signal only — returns that signal unchanged. */
  static async caseCallerOnly(): Promise<void> {
    const controller = new AbortController();
    const signal = await Signal.compose({ 'signal': controller.signal });

    assert.strictEqual(signal, controller.signal, 'returns the caller signal directly');
    assert.ok(!signal.aborted, 'signal is not aborted');
    console.log(`caseCallerOnly: same reference=${signal === controller.signal}`);
  }

  /** Case 3: deadlineMs only — returns a timeout signal. */
  static async caseDeadlineOnly(): Promise<void> {
    const signal = await Signal.compose({ 'deadlineMs': 5000 });

    assert.ok(signal instanceof AbortSignal, 'returns an AbortSignal');
    assert.ok(!signal.aborted, 'not yet aborted at 5 s');
    console.log(`caseDeadlineOnly: aborted=${signal.aborted}`);
  }

  /** Case 4: neither — returns the never-aborting sentinel. */
  static async caseNeither(): Promise<void> {
    const composed = await Signal.compose({});
    const sentinel = Signal.never();

    assert.strictEqual(composed, sentinel, 'compose({}) returns the same object as Signal.never()');
    assert.ok(!composed.aborted, 'sentinel is never aborted');
    console.log(`caseNeither: compose({}) === Signal.never() → ${composed === sentinel}`);
  }

  /** deadlineMs of 0 is valid (non-negative) — AbortSignal.timeout(0) aborts immediately. */
  static async caseZeroDeadline(): Promise<void> {
    const signal = await Signal.compose({ 'deadlineMs': 0 });
    assert.ok(signal instanceof AbortSignal, 'zero deadline produces an AbortSignal');
    console.log(`caseZeroDeadline: is AbortSignal=${signal instanceof AbortSignal}`);
  }

  /** Negative deadlineMs throws SignalError. */
  static async caseInvalidNegative(): Promise<void> {
    let caught: unknown;
    try {
      await Signal.compose({ 'deadlineMs': -1 });
    } catch (err) {
      caught = err;
    }
    assert.ok(caught instanceof SignalError, 'throws SignalError for negative deadlineMs');
    console.log(`caseInvalidNegative: threw SignalError=${caught instanceof SignalError}`);
  }

  /** NaN deadlineMs throws SignalError. */
  static async caseInvalidNaN(): Promise<void> {
    let caught: unknown;
    try {
      await Signal.compose({ 'deadlineMs': NaN });
    } catch (err) {
      caught = err;
    }
    assert.ok(caught instanceof SignalError, 'throws SignalError for NaN deadlineMs');
    console.log(`caseInvalidNaN: threw SignalError=${caught instanceof SignalError}`);
  }

  /** Non-number deadlineMs (cast) throws SignalError. */
  static async caseInvalidNonNumber(): Promise<void> {
    let caught: unknown;
    try {
      await Signal.compose({ 'deadlineMs': 'soon' as unknown as number });
    } catch (err) {
      caught = err;
    }
    assert.ok(caught instanceof SignalError, 'throws SignalError for non-number deadlineMs');
    console.log(`caseInvalidNonNumber: threw SignalError=${caught instanceof SignalError}`);
  }
}

await ComposeDemo.caseCallerAndDeadline();
await ComposeDemo.caseCallerOnly();
await ComposeDemo.caseDeadlineOnly();
await ComposeDemo.caseNeither();
await ComposeDemo.caseZeroDeadline();
await ComposeDemo.caseInvalidNegative();
await ComposeDemo.caseInvalidNaN();
await ComposeDemo.caseInvalidNonNumber();

Never-aborting sentinel and timeout signal

The sentinel is a singleton: Signal.never() returns the same AbortSignal instance on every call. Signal.timeout(ms) wraps AbortSignal.timeout for convenience.

ts
import { Signal } from '../src/index.js';

class NeverTimeoutDemo {
  /** Signal.never() returns the same singleton on every call. */
  static neverIsSingleton(): void {
    const a = Signal.never();
    const b = Signal.never();
    const c = Signal.never();

    assert.strictEqual(a, b, 'first and second calls return the same object');
    assert.strictEqual(b, c, 'second and third calls return the same object');
    console.log(`neverIsSingleton: a===b=${a === b}, b===c=${b === c}`);
  }

  /** Signal.never() is never aborted. */
  static neverIsNotAborted(): void {
    const signal = Signal.never();

    assert.ok(!signal.aborted, 'sentinel is not aborted');
    console.log(`neverIsNotAborted: aborted=${signal.aborted}`);
  }

  /** Signal.timeout returns an AbortSignal that is not yet aborted for a generous deadline. */
  static timeoutNotYetAborted(): void {
    const signal = Signal.timeout(5000);

    assert.ok(signal instanceof AbortSignal, 'Signal.timeout returns an AbortSignal');
    assert.ok(!signal.aborted, 'signal with 5 s deadline is not yet aborted');
    console.log(`timeoutNotYetAborted: aborted=${signal.aborted}`);
  }

  /** Signal.timeout with distinct durations returns distinct signal instances. */
  static timeoutReturnsDistinctInstances(): void {
    const a = Signal.timeout(1000);
    const b = Signal.timeout(2000);

    assert.notStrictEqual(a, b, 'different timeouts are distinct AbortSignal instances');
    console.log(`timeoutReturnsDistinctInstances: a===b=${a === b}`);
  }
}

NeverTimeoutDemo.neverIsSingleton();
NeverTimeoutDemo.neverIsNotAborted();
NeverTimeoutDemo.timeoutNotYetAborted();
NeverTimeoutDemo.timeoutReturnsDistinctInstances();

Try it

AbortSignal composition — all four cases
/** compose — demonstrates all four Signal.compose cases. Run: npx tsx examples/compose.ts */

import assert from 'node:assert/strict';

// #region usage
import { Signal, SignalError } from '../src/index.js';

class ComposeDemo {
  /** Case 1: both caller signal + deadlineMs — returns AbortSignal.any composite. */
  static async caseCallerAndDeadline(): Promise<void> {
    const controller = new AbortController();
    const signal = await Signal.compose({ 'deadlineMs': 5000, 'signal': controller.signal });

    assert.ok(signal instanceof AbortSignal, 'composite is an AbortSignal');
    assert.ok(!signal.aborted, 'composite is not yet aborted');
    console.log(`caseCallerAndDeadline: aborted=${signal.aborted}`);
  }

  /** Case 2: caller signal only — returns that signal unchanged. */
  static async caseCallerOnly(): Promise<void> {
    const controller = new AbortController();
    const signal = await Signal.compose({ 'signal': controller.signal });

    assert.strictEqual(signal, controller.signal, 'returns the caller signal directly');
    assert.ok(!signal.aborted, 'signal is not aborted');
    console.log(`caseCallerOnly: same reference=${signal === controller.signal}`);
  }

  /** Case 3: deadlineMs only — returns a timeout signal. */
  static async caseDeadlineOnly(): Promise<void> {
    const signal = await Signal.compose({ 'deadlineMs': 5000 });

    assert.ok(signal instanceof AbortSignal, 'returns an AbortSignal');
    assert.ok(!signal.aborted, 'not yet aborted at 5 s');
    console.log(`caseDeadlineOnly: aborted=${signal.aborted}`);
  }

  /** Case 4: neither — returns the never-aborting sentinel. */
  static async caseNeither(): Promise<void> {
    const composed = await Signal.compose({});
    const sentinel = Signal.never();

    assert.strictEqual(composed, sentinel, 'compose({}) returns the same object as Signal.never()');
    assert.ok(!composed.aborted, 'sentinel is never aborted');
    console.log(`caseNeither: compose({}) === Signal.never() → ${composed === sentinel}`);
  }

  /** deadlineMs of 0 is valid (non-negative) — AbortSignal.timeout(0) aborts immediately. */
  static async caseZeroDeadline(): Promise<void> {
    const signal = await Signal.compose({ 'deadlineMs': 0 });
    assert.ok(signal instanceof AbortSignal, 'zero deadline produces an AbortSignal');
    console.log(`caseZeroDeadline: is AbortSignal=${signal instanceof AbortSignal}`);
  }

  /** Negative deadlineMs throws SignalError. */
  static async caseInvalidNegative(): Promise<void> {
    let caught: unknown;
    try {
      await Signal.compose({ 'deadlineMs': -1 });
    } catch (err) {
      caught = err;
    }
    assert.ok(caught instanceof SignalError, 'throws SignalError for negative deadlineMs');
    console.log(`caseInvalidNegative: threw SignalError=${caught instanceof SignalError}`);
  }

  /** NaN deadlineMs throws SignalError. */
  static async caseInvalidNaN(): Promise<void> {
    let caught: unknown;
    try {
      await Signal.compose({ 'deadlineMs': NaN });
    } catch (err) {
      caught = err;
    }
    assert.ok(caught instanceof SignalError, 'throws SignalError for NaN deadlineMs');
    console.log(`caseInvalidNaN: threw SignalError=${caught instanceof SignalError}`);
  }

  /** Non-number deadlineMs (cast) throws SignalError. */
  static async caseInvalidNonNumber(): Promise<void> {
    let caught: unknown;
    try {
      await Signal.compose({ 'deadlineMs': 'soon' as unknown as number });
    } catch (err) {
      caught = err;
    }
    assert.ok(caught instanceof SignalError, 'throws SignalError for non-number deadlineMs');
    console.log(`caseInvalidNonNumber: threw SignalError=${caught instanceof SignalError}`);
  }
}

await ComposeDemo.caseCallerAndDeadline();
await ComposeDemo.caseCallerOnly();
await ComposeDemo.caseDeadlineOnly();
await ComposeDemo.caseNeither();
await ComposeDemo.caseZeroDeadline();
await ComposeDemo.caseInvalidNegative();
await ComposeDemo.caseInvalidNaN();
await ComposeDemo.caseInvalidNonNumber();
// #endregion usage

console.log('compose: all assertions passed');
Output
Press Execute to run this example against the real library.

The output confirms each composition case: caller+deadline composite, caller-only passthrough, deadline-only timeout, the never-aborting sentinel, and SignalError thrown for invalid deadline values.

API

ExportTypeDescription
SignalclassStatic-only AbortSignal composition helpers

Signal

MemberSignatureDescription
composestatic (options: { signal?, deadlineMs? }) => AbortSignalMerges caller signal and/or timeout; returns never-signal when neither is provided
timeoutstatic (ms: number) => AbortSignalReturns AbortSignal.timeout(ms)
neverstatic () => AbortSignalReturns a singleton signal that never aborts

Source on GitHub