decorator-toolkit
v0.5.0
Published
Modern TC39 decorators for reducing repetitive code.
Maintainers
Readme
The patterns you know from Polly, resilience4j, tenacity or Go's sync and
singleflight, as one-line decorators: retry, timeout, circuit breaker,
fallback, rate limit, bulkhead, memoize, singleflight, once, debounce,
throttle. No runtime dependencies. Works in browsers, Node 22+, Bun and Deno.
Installation
npm install decorator-toolkit
# or
bun add decorator-toolkitUsage
The package targets standard TC39 decorators (TypeScript 5+). Use a modern compiler configuration:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16"
}
}This package ships its types from source, so the compiler needs the globals it
uses (setTimeout, performance, DOMException). A browser project gets them
from "lib": ["DOM", ...]; a Node project needs @types/node, listed in
"types" when you are on TypeScript 7, which no longer includes every @types
package automatically.
[!NOTE] Method decorators apply to methods only,
bindAllapplies to classes,readonlyapplies toaccessormembers andlazytogetaccessors. Private members are not supported. Decorators that need no configuration accept both@decoratorand@decorator().
Resilience pipeline
import {
circuitBreaker,
onError,
retry,
timeout,
} from "decorator-toolkit";
class PricingClient {
@onError<PricingClient, number, [string]>(() => 0) // fallback
@circuitBreaker({ failures: 5, resetMs: 30_000 }) // stop hammering a dead service
@retry({ retries: 3, delay: (attempt) => 200 * 2 ** attempt }) // exponential backoff
@timeout(2_000) // DOMException "TimeoutError"
async price(sku: string): Promise<number> {
const response = await fetch(`https://pricing.example/${sku}`);
return Number(await response.text());
}
}Caching, deduplication and limits
import {
cache,
concurrent,
delegate,
rateLimit,
runOnce,
} from "decorator-toolkit";
class Directory {
@runOnce // lazy init, concurrent callers share the promise
async connect(): Promise<void> {}
@cache({ ttlMs: 5_000 }) // memoize by arguments, lazy TTL
lookup(id: string): string {
return `user:${id}`;
}
@delegate // singleflight: identical concurrent calls share one request
async load(id: string): Promise<object> {
return fetch(`/users/${id}`).then((r) => r.json());
}
@concurrent(4) // bulkhead: at most 4 in flight, the rest queue
async sync(id: string): Promise<void> {}
@rateLimit<Directory, [string]>({ allowedCalls: 10, timeSpanMs: 60_000, keyResolver: (id) => id })
openProfile(id: string): string {
return `/users/${id}`;
}
}Lifecycle
import {
dispose,
lazy,
periodic,
readonly,
} from "decorator-toolkit";
class Session {
declare [Symbol.dispose]: () => void;
@readonly
accessor id = crypto.randomUUID();
@lazy
get config(): object {
return buildExpensiveConfig(); // once per instance
}
@periodic({ intervalMs: 5_000, immediate: true })
async heartbeat(): Promise<void> {}
@dispose
close(): void {}
}
{
using session = new Session();
} // heartbeat stops, close() runsImports
import {
retry,
timeout,
} from "decorator-toolkit";
import { cache } from "decorator-toolkit/cache";
import {
circuitBreaker,
CircuitOpenError,
} from "decorator-toolkit/circuit-breaker";Legacy experimentalDecorators projects
TypeORM, NestJS and similar stacks require experimentalDecorators, which
switches the whole compilation to the old decorator signature. Every decorator
here detects that call form at runtime, so the same imports and the same
@retry(3) work in both worlds. Differences under experimentalDecorators:
bind binds on first access instead of at construction, dispose wires the
prototype, readonly and lazy decorate get/set accessors, and periodic is
unavailable because it needs a class initializer.
Available Decorators
| Pattern | Decorator | Purpose |
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
| Retry | retry | Retries a rejected async method with a fixed or computed delay |
| Timeout | timeout | Rejects slow async methods with a DOMException named TimeoutError |
| Circuit breaker | circuitBreaker | Fails fast after N consecutive failures, probes again after a cooldown |
| Fallback | onError | Routes thrown errors and rejections to a handler whose result is returned |
| Hedging | multiDispatch | Starts N identical async calls and resolves with the first success |
| Rate limit | rateLimit | Refuses calls above a count per sliding window, per instance or key |
| Bulkhead | concurrent | Limits in-flight async calls per instance; extra calls queue in order |
| Memoize | cache | Caches results by arguments with an optional TTL; evicts rejected promises |
| Singleflight | delegate | Shares one in-flight async call across callers with the same key |
| Once | runOnce | Runs once per instance and returns the first result to later calls |
| Lazy | lazy | Computes a getter once per instance |
| Debounce | debounce | Coalesces rapid calls into one later execution |
| Throttle | throttle | Runs at most once per window; calls in between are dropped |
| Latest wins | cancelPrevious | Rejects the previous pending call with a DOMException named AbortError |
| Delay | delay | Schedules the call after a fixed delay |
| Periodic | periodic | Calls the method on an interval until the instance is disposed |
| Dispose | dispose | Wires a method to Symbol.dispose / Symbol.asyncDispose for using |
| Hooks | before | Runs a hook before the method |
| | after | Runs a hook after the method, optionally after the promise resolves |
| | execTime | Reports execution time via performance.now() |
| Binding | bind | Binds a method to its instance during initialization |
| | bindAll | Binds all methods declared on a class |
| Readonly | readonly | Makes an accessor write-protected |
Documentation
Start with docs/README.md for the grouped reference; every
decorator has its own page under docs/decorators/.
