const makeDomException = (message: string, name: string): Error => {
try { return new DOMException(message, name) } catch { return Object.assign(new Error(message), { name }) }
}
export type AnySignal = {
signal: AbortSignal
dispose: () => void
}
export const anySignal = (...signals: Array<AbortSignal | undefined>): AnySignal => {
const controller = new AbortController()
const done = () => listeners.forEach(l => l())
const abortWith = (reason?: unknown) => {
if (!controller.signal.aborted) controller.abort(reason ?? makeDomException('Aborted', 'AbortError'))
done()
}
const listeners: Array<() => void> = []
for (const s of signals) {
if (!s) continue
if (s.aborted) {
abortWith((s as unknown as { reason?: unknown }).reason)
break
}
const onAbort = () => abortWith((s as unknown as { reason?: unknown }).reason)
s.addEventListener('abort', onAbort, { once: true })
listeners.push(() => s.removeEventListener('abort', onAbort))
}
return { signal: controller.signal, dispose: done }
}
export const timeoutSignal = (ms: number): AnySignal => {
const controller = new AbortController()
const id = setTimeout(() => {
controller.abort(makeDomException('Timed out', 'TimeoutError'))
}, Math.max(0, ms))
const dispose = () => clearTimeout(id)
return { signal: controller.signal, dispose }
}
// Example:
// Cancel fetch if either: the caller’s signal aborts OR we hit 8s timeout.
async function getJson(url: string, init?: RequestInit & { signal?: AbortSignal }) {
const t = timeoutSignal(8000)
const merged = anySignal(init?.signal, t.signal)
try {
const res = await fetch(url, { ...init, signal: merged.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
} finally {
t.dispose()
merged.dispose()
}
}Aborting requests is easier when you can treat “any of these signals” as a single signal. This tiny utility turns multiple AbortSignals into one merged signal that aborts as soon as any input aborts. It also preserves the original abort reason, so downstream consumers can distinguish between a user cancellation (AbortError) and a timeout (TimeoutError). The companion timeoutSignal(ms) utility gives you a drop‑in signal that aborts after a specified duration and returns a dispose() to clear the timer.
anySignal(...signals) works in two steps. First, it handles already‑aborted inputs: if any provided signal is already aborted, it immediately aborts the merged controller with that signal’s reason. Second, it attaches one‑time abort listeners to each signal; on the first abort, it aborts the merged controller with the same reason and then calls dispose() to remove all listeners. Returning a dispose function prevents listener leaks when you’re done early or reuse the combiner across operations.
timeoutSignal(ms) clamps negative durations to 0 and aborts with a DOMException('Timed out', 'TimeoutError'). Pair it with anySignal to express common patterns like “cancel if the caller aborts or if we exceed 8 seconds.” The fetch example shows the typical lifecycle: construct signals, pass the merged signal to fetch, and unconditionally clean up in finally using both dispose() calls.
Notes and trade‑offs:
AbortSignal.reason; this code reads it defensively via a loose cast for compatibility. If no reason is present, a standard AbortError is used.DOMException. The helper uses makeDomException() to fall back to an Error with the name set, keeping types and messaging consistent.AbortSignal.timeout(ms) is a native alternative. timeoutSignal(ms) still earns its keep for compatibility and a unified API with dispose().anySignal() with no arguments returns a signal that never aborts—reasonable by design, but worth knowing.anySignal when composition is clearer or unavoidable.Build a zero-dependency deep clone helper that preserves literal types while copying arrays, Maps, Sets, and Dates without mutating the source.
Drop sensitive object fields safely by pairing a tiny omit helper with literal key inference and runtime filtering.
Preserve exact key inference when slicing object shapes so you never lose type coverage while building derived views.
Preserve key-value correlations from Object.entries so your loops stay perfectly typed without manual assertions.
Keep Object.keys aligned with your literal key types so mapped iterations stay safe without casts.