@master4n/decorators
v2.0.5
Published
AI-friendly TypeScript decorators for Node/backend apps, in ten families: Inject (config & value injection), Guard (validation), Shape (transforms), Shield (access control & secret redaction), Flow (resilience — retry/timeout/circuit-breaker/rate-limit/ca
Maintainers
Keywords
Readme
@master4n/decorators
AI-friendly TypeScript decorators for Node/backend apps. One self-documenting decorator replaces a block of boilerplate. Designed so coding agents emit one correct line instead of ten repetitive ones.
Decorator families
Every decorator belongs to one of ten families — a quick mental map for picking the right one:
| Family | Purpose | Examples |
| ------ | ------- | -------- |
| Inject | pull values into fields | @Value @Env @Secret @Config @Default @Configured |
| Guard | reject invalid input (throws) | @NotNull @Pattern @Min @Max @Range @Email @URL @UUID @Enum @Size @NotBlank @Past @Future @AssertTrue @Digits … |
| Shape | normalize values on assign | @Trim @Lowercase @Uppercase @Coerce @Clamp |
| Shield | access control & secret redaction | @Role @Authorize @Secret + redact() |
| Flow | resilience & control flow | @Timeout @Retry @Cache @Dedupe @Fallback @RateLimit @Concurrency @CircuitBreaker @Debounce @Throttle @Once |
| Insight | observability | @Log @Trace @Audit @LogErrors @Measure @Deprecated |
| Model | data/domain classes | @Data @ToString @Equals @With @Immutable @Readonly @Builder @GenerateID @Counter @Synchronized |
| Route | HTTP REST controllers | @Controller @Get @Post @Param @Query @Body @HttpCode @Use … |
| Agent | LLM tools & safety | @Tool @Validate @Guardrail @Idempotent @Meter + getTools() / invokeTool() / getMetrics() |
| Craft | class & method ergonomics | @Bind @Lazy @Sealed @Mixin @OnChange |
Recipes for AI agents
Real, copy-paste compositions. Each replaces a page of hand-written plumbing with a stack of declarations.
A bullet-proof DTO (Shape + Guard)
import { Configured, Trim, Lowercase, Email, NotBlank, Size, Coerce, Range } from '@master4n/decorators';
@Configured
class SignupDto {
@Trim @Lowercase @Email() email!: string; // normalized, then validated
@Trim @NotBlank() @Size(3, 20) username!: string;
@Coerce('number') @Range(18, 120) age!: number; // "21" -> 21, bounded
}
// Assigning a bad value throws ValidationError at the source — no manual checks.A resilient upstream client (Flow)
import { Fallback, CircuitBreaker, Retry, Timeout, Cache } from '@master4n/decorators';
class UserApi {
@Fallback(null) // OUTERMOST = last resort: catch after everything else
@CircuitBreaker({ failureThreshold: 5, resetMs: 30_000 })
@Retry(3, { delayMs: 200 }) // retry the timed call
@Timeout(5_000)
@Cache(60_000) // INNERMOST: memoize the actual fetch
async getUser(id: string) {
return (await fetch(`/users/${id}`)).json();
}
}Decorator order matters. Stacks apply bottom-up: the decorator nearest the method wraps the original first, and the top decorator runs first / sees the final outcome. So put recovery (
@Fallback) outermost (top) — otherwise it swallows the error before@Retry/@CircuitBreakerever see a failure, and retries silently never happen.
A safe, observable AI tool (Agent + Insight)
import { Tool, Validate, Idempotent, Guardrail, Meter, Trace, getTools, invokeTool, getMetrics } from '@master4n/decorators';
class BookingTools {
@Tool({
description: 'Book a room for a guest',
parameters: {
type: 'object',
properties: { guest: { type: 'string' }, nights: { type: 'number' } },
required: ['guest', 'nights'],
},
})
@Trace() // correlation-id traced
@Meter('book_room') // counts + timing -> getMetrics()
@Idempotent((args) => `${args.guest}:${args.nights}`) // safe to retry
@Validate((args) => (args[0] as any)?.nights > 0) // reject bad tool input
@Guardrail((res: { confirmed: boolean }) => res.confirmed, { retries: 1 }) // verify output
async bookRoom(args: { guest: string; nights: number }) {
return { confirmed: true, ref: 'BK-123' };
}
}
const svc = new BookingTools();
const tools = getTools(); // -> hand to the LLM
// model picks a tool ...
await invokeTool(svc, 'bookRoom', { guest: 'Asha', nights: 2 });
getMetrics().book_room; // { calls, errors, avgMs, ... }An immutable domain model (Model)
import { Data, Immutable, builder } from '@master4n/decorators';
@Immutable
@Data // toString + equals + with
class Money { constructor(public amount = 0, public currency = 'INR') {} }
const a = new Money(100, 'INR');
const b = (a as any).with({ amount: 250 }); // frozen copy, original untouched
const c = builder(Money).amount(50).currency('USD').build(); // typed builderLess boilerplate (Craft)
import { Configured, Bind, Lazy, OnChange } from '@master4n/decorators';
@Configured // required for property decorators under modern TS
class Editor {
@Lazy((self) => buildHeavyIndex(self)) index!: Index; // computed once, on first read
@OnChange((v) => autosave(v)) content = ''; // reacts to real changes
@Bind onClick() { return this.content; } // safe to detach
}
@Lazyand@OnChangeare property decorators — like all of them, add@Configuredto the class when you compile withuseDefineForClassFields: true(the modern default), or they silently no-op (see TypeScript setup).@Bindis a method decorator and needs no@Configured.
Installation
npm install @master4n/decoratorsThe core has zero runtime dependencies. Two features are gated behind optional peer dependencies — install them only if you use those features:
npm i config # for @Value / @Config from "@master4n/decorators/config"
npm i winston # for redactFormat from "@master4n/decorators/winston"TypeScript setup
This is a legacy-decorator library. A complete, known-good tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}The one rule that matters: put
@Configuredon any class that uses a property decorator (@Value/@Env/@Secret/@Config/@Default, every Guard like@Min/@Pattern, every Shape like@Trim, and Craft's@Lazy/@OnChange/@Readonly). With modern TS (useDefineForClassFields: true, the default fortarget >= ES2022) a class field shadows the decorator's prototype accessor, so without@Configuredthose decorators silently no-op — no error, just nothing happens.@Configuredmaterializes them as own instance properties so they work under any setting. Method and class decorators (@Get,@Retry,@Bind,@Data,@Tool, …) don't need it.
Inject — config & value injection (flagship)
Stop hand-writing process.env.X ?? config.get(...) ?? default plus type
coercion, for every field.
// BEFORE — written by hand (or by an agent) for every single field:
class DbConfig {
url: string;
port: number;
ssl: boolean;
constructor() {
this.url = process.env.DB_URL ?? config.get('db.url') ?? 'sqlite://memory';
const p = process.env.DB_PORT;
this.port = p ? parseInt(p, 10) : 5432; // string -> number
this.ssl = (process.env.DB_SSL ?? 'false') === 'true'; // string -> boolean
}
}// AFTER — declarative, coerced, and fails loud on missing required keys:
import { Configured, Env, Secret } from '@master4n/decorators';
import { Value } from '@master4n/decorators/config'; // node-config-backed
@Configured
class DbConfig {
@Value('db.url', 'sqlite://memory') url!: string;
@Env('DB_PORT', 5432) port!: number; // "5432" -> 5432
@Env('DB_SSL', false) ssl!: boolean; // "true" -> true
@Secret('DB_PASSWORD') password!: string; // required; tracked for redaction
}Zero runtime dependencies. The main entry point pulls in no third-party packages. The two decorators that read config files —
@Value/@Config— live in the@master4n/decorators/configsubpath and requirenode-configas an optional peer dependency (npm i config). Everything else, including@Env/@Secret, is dependency-free.Sources:
@Value/@Configread YAML/JSON vianode-config;@Env/@Secretreadprocess.env. Node does not load.envfiles automatically — if you keep config in a.envfile, load it first (node --env-file=.env ...or thedotenvpackage) so the variables are present inprocess.env.
Injection decorators
| Decorator | Source | Notes |
| ------------------------ | ------------------------------- | ---------------------------------------------------------------- |
| @Value(key, default?) | config files (YAML/JSON) | via node-config. Required (throws) when no default is given. |
| @Env(name, default?) | process.env | coerces to the default's type (number/boolean/array). |
| @Secret(name, default?)| process.env | like @Env; marks the field as secret so redact() / redactFormat() mask it (see Secret redaction). |
| @Config(path) | config files | injects a whole config subtree/object (required). |
| @Default(value) | literal | injects a constant. |
| @Configured | class decorator | materializes the above as own instance props (robust mode). |
Missing required values throw MissingConfigError. With @Configured, that
happens at construction — so misconfiguration fails at startup, not deep in a
request.
Secret redaction
@Secret doesn't just track names — it makes those values disappear from logs.
redact(value, options?)returns a deep copy with sensitive values masked ('[REDACTED]'). Sensitive =@Secret-marked property names ∪ a built-in list (DEFAULT_SENSITIVE_KEYS:password,token,apiKey,authorization, …) ∪options.keys. Matching is case- and_/--insensitive and matches secret stems as substrings, so compound names likejwtSecret,apiToken, anduserPasswordare masked too. Nested objects, arrays,Map/Set, and circular references are handled; values pastmaxDepth(12) become'[Truncated]'so deep secrets can't leak.redactFormat(options?)is a winston format, exported from the optional@master4n/decorators/winstonsubpath (winston is an optional peer dependency). Add it to your logger'sformat.combine(...)to protect your logs too.redact()itself is dependency-free and lives on the main entry.
Redaction is key-based — it masks object fields whose name is sensitive. It cannot mask a secret passed as a positional primitive (e.g.
login(rawToken)) or one embedded in an error message/stack. Pass secrets as named object fields, and don't put them in error messages.
@Secretfield names are registered process-globally, soredact()masks that field name everywhere — pick distinctive secret field names (jwtSecret, notid) to avoid over-masking unrelated fields.
import { redact } from '@master4n/decorators';
import { redactFormat } from '@master4n/decorators/winston'; // optional peer: winston
import winston from 'winston';
const logger = winston.createLogger({
format: winston.format.combine(redactFormat(), winston.format.json()),
});
logger.info('Loaded config', redact(appConfig)); // jwtSecret -> [REDACTED]Guard & Shield — validation & access
Guards throw on invalid input — misuse fails fast instead of slipping through.
| Decorator | Target | Throws | Description |
| -------------------------- | ------ | ----------------- | ------------------------------------------------------ |
| @NotNull | method | ValidationError | rejects null/undefined arguments. |
| @ValidDate | method | ValidationError | first arg must be a valid { DD, MM, YYYY } date. |
| @Pattern(regex, opts?) | property | ValidationError | only matches the regex. ⚠️ ReDoS: set { maxLength } for untrusted input. |
| @Min(n) / @Max(n) | property | ValidationError | string/array length ≥ n / ≤ n, or number value. |
| @Range(min, max) | property | ValidationError | inclusive bounds on string/array length or number value. |
| @Email @URL @UUID | property | ValidationError | format checks for email / URL / UUID. |
| @Enum(values) | property | ValidationError | value must be one of values. |
| @NonEmpty | property | ValidationError | rejects null/undefined/''/[]. |
| @Integer @Positive | property | ValidationError | number must be an integer / greater than zero. |
| @NotBlank | property | ValidationError | string with a non-whitespace char (asserts presence). |
| @Size(min, max) | property | ValidationError | string/array length bounds. |
| @Negative @PositiveOrZero @NegativeOrZero | property | ValidationError | number sign constraints. |
| @Past @Future @PastOrPresent @FutureOrPresent | property | ValidationError | date is before/after now. |
| @AssertTrue @AssertFalse | property | ValidationError | boolean must be true / false. |
| @Digits(int, frac) | property | ValidationError | max integer + fractional digits. |
Transforms normalize the value on assignment (and run before validators, whatever the stacking order):
| Decorator | Effect |
| --------------- | ------------------------------------------------- |
| @Trim | trim whitespace from assigned strings. |
| @Lowercase / @Uppercase | change case of assigned strings. |
| @Coerce(type) | coerce to 'number'/'boolean'/'string'. |
| @Clamp(min, max) | clamp an assigned number into [min, max]. |
@Configured
class Signup {
@Trim @Lowercase @Email() email!: string; // " [email protected] " -> "[email protected]", validated
@Coerce('number') @Range(18, 120) age!: number; // "21" -> 21, bounded
}| @Role(...roles) | method | ForbiddenError | allows only if the principal has one of the roles. |
| @Authorize(predicate) | method | ForbiddenError | allows only if predicate(ctx) is truthy. |
@Role/@Authorize are auth-agnostic. Register how to find the principal once:
import { Role, Authorize, setRoleResolver } from '@master4n/decorators';
setRoleResolver((ctx) => (ctx.instance as any).user?.roles ?? []);
class AdminApi {
@Role('admin', 'owner')
deleteUser(id: string) { /* ... */ }
@Authorize((ctx) => (ctx.instance as any).user?.can('billing'))
refund(orderId: string) { /* ... */ }
}Resolvers/predicates may be async (the guarded call then returns a promise).
@Pattern guards a property: assignments that don't match the regex throw and
the previous value is kept. Add @Configured so it works under any
useDefineForClassFields setting (like the injection decorators).
import { Configured, Pattern } from '@master4n/decorators';
@Configured
class User {
@Pattern(/^[^@\s]+@[^@\s]+\.[^@\s]+$/, { message: 'invalid email' })
email!: string;
@Pattern(/^\d{6}$/, { coerce: true }) // accepts 560001 or "560001"
pincode!: string;
}
new User().email = 'not-an-email'; // throws ValidationError@Min/@Max/@Range are polymorphic — they check string/array length or a
number's value — and compose with each other and @Pattern:
@Configured
class Account {
@Pattern(/^[a-z0-9_]+$/) @Min(3) @Max(20)
username!: string; // lowercase, 3–20 chars
@Range(0, 100) score!: number; // 0..100
}Utility decorators
| Decorator | Target | Description |
| ------------------ | --------------- | ------------------------------------------------------ |
| @GenerateID | class property | assigns a lazy UUIDv4 (via crypto.randomUUID()). |
| @Counter | static property | auto-incrementing counter on each read. |
| @Log(opts?) | method | logs entry/exit; { args, result } also log redacted args/return; { level } sets the level. |
| @Retry(n, opts?) | method | retries on failure (sync/async); opts.delayMs for async. |
| @Memoize | method | caches results by a stable arg key, per instance; failures aren't memoized. |
| @Deprecated(msg) | method | logs a one-time deprecation warning. |
| @Measure | method | logs execution time (sync/async). |
Model — data classes
| Decorator | Adds |
| ---------------- | ------------------------------------------------------------------------- |
| @ToString(opts?) | a toString() listing fields — with @Secret/sensitive fields redacted. only/exclude options. |
| @Equals(...keys?)| an equals(other) (same-constructor, field-wise). |
| @With | with(patch) → shallow copy with overrides (frozen-preserving). |
| @Data | @ToString + equals() + with() in one. |
| @Immutable | Object.freeze each instance (immutable value object). Pairs with @With. |
| @Readonly | field: assignable once, then throws (like final). |
| @Synchronized | method: serialize concurrent async calls per instance (mutex). |
| @Builder / builder(Class) | fluent builder. builder() is fully typed (no codegen). |
import { Data, Immutable, With, builder } from '@master4n/decorators';
@Immutable
@Data // toString + equals + with
class Money { constructor(public amount = 0, public currency = 'INR') {} }
const a = new Money(100, 'INR');
const b = (a as any).with({ amount: 250 }); // frozen copy
const c = builder(Money).amount(50).currency('USD').build(); // typed builderRoute — REST controllers
Build Express routes declaratively — @Controller + @GetMapping +
@PathVariable/@RequestParam/@RequestBody — then wire them in with
registerControllers. Framework-agnostic (no express dependency); works with
any Express-compatible app/Router.
import express from 'express';
import {
Controller, Get, Post, Param, Query, Body, HttpCode, Use, registerControllers,
} from '@master4n/decorators';
@Use(authMiddleware) // controller-level middleware
@Controller('/users')
class UserController {
@Get('/:id')
getUser(@Param('id') id: string, @Query('expand') expand?: string) {
return this.service.find(id, expand); // returned value -> res.json(...) (200)
}
@Post('/')
@HttpCode(201)
create(@Body() dto: CreateUserDto) {
return this.service.create(dto); // -> 201 + JSON
}
}
const app = express();
app.use(express.json());
registerControllers(app, [new UserController()]);Returned values are sent as JSON with the configured status; throw and it's
routed to next(err). Inject @Res() to take over the response yourself.
| Concise | Alias | Purpose |
| ------------------ | ------------------- | ----------------------------------------- |
| @Controller(base)| @RestController | class: base path + controller middleware. |
| @Get @Post @Put @Patch @Delete @Options @Head @All | @GetMapping … @RequestMapping | route a method. |
| @Param(n) | @PathVariable | path variable. |
| @Query(n) | @RequestParam | query-string value. |
| @Body(n?) | @RequestBody | request body (or one field). |
| @Header(n) | @RequestHeader | request header. |
| @Cookie(n) @Req() @Res() @Next() | — | cookie / raw req / res / next. |
| @HttpCode(code) | @ResponseStatus | success status code. |
| @ContentType(t) | @Produces | response content-type. |
| @Redirect(url) @Use(...mw) | — | redirect / attach middleware (class or route). |
Agent — LLM tools
Expose class methods as LLM-callable tools, then dispatch the model's tool call back to the method — the whole agent loop, declaratively.
import { Tool, getTools, invokeTool } from '@master4n/decorators';
class WeatherService {
@Tool({
description: 'Get the current temperature for a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
})
getTemperature(args: { city: string }) { /* ... */ }
}
const tools = getTools();
// -> [{ name: 'getTemperature', description: '...', parameters: {...} }]
// Pass `tools` to your LLM (OpenAI `tools`/`parameters`, Anthropic `input_schema`).
// When the model returns a tool call:
const result = invokeTool(new WeatherService(), call.name, call.arguments);parameters is an explicit JSON Schema — TypeScript parameter types are
erased at runtime, so the library does not (and cannot honestly) infer it.
Tool names live in a process-global registry and must be unique — a duplicate name overwrites the earlier one (so
invokeToolwould target the wrong method). Give colliding tools an explicit uniquename.
Agent power-ups (method decorators)
Wrap the methods an agent calls so they're validated, safe to retry, verified, and measured:
| Decorator | Does |
| ------------------------ | -------------------------------------------------------------------- |
| @Validate(check) | reject bad input args before running (throws ValidationError). |
| @Guardrail(check, opts?) | verify the output; retry up to opts.retries, else GuardrailError. |
| @Idempotent(keyFn?, { maxSize? }) | cache the result by an idempotency key — safe to retry (no TTL; failures aren't cached). maxSize bounds memory (LRU). |
| @Meter(name?) | record calls / errors / timing; read with getMetrics(). Metrics are process-global, keyed by name — use distinct names to meter separately. |
Craft — class & method ergonomics
Kill the small repeated boilerplate.
| Decorator | Does |
| ----------------- | -------------------------------------------------------------------------- |
| @Bind | auto-bind a method to its instance — no more .bind(this) for callbacks. |
| @Lazy(factory) | compute a property once on first access, then cache. |
| @Sealed | Object.seal each instance (no added/removed props; values stay writable). |
| @Mixin(...src) | copy members from other objects/classes onto the class. |
| @OnChange(fn) | run fn(new, old, instance) when a property actually changes (first set initializes silently). |
Insight — observability
| Decorator | Description |
| ----------------- | ----------------------------------------------------------------------- |
| @Trace(opts?) | structured entry/exit/error logs with a correlation id threaded through nested calls (AsyncLocalStorage). Args/results redacted. getTraceId() reads the current id. |
| @Audit(action?) | logs actor + action + redacted args. Set the "who" via setAuditResolver. |
| @LogErrors() | logs errors (redacted args + stack) and rethrows (sync/async). |
import { Trace, Audit, setAuditResolver, getTraceId } from '@master4n/decorators';
setAuditResolver((ctx) => (ctx.instance as any).user?.id ?? 'system');
class OrderService {
@Trace({ result: true }) // one trace id flows through the whole call tree
@Audit('order.refund')
async refund(orderId: string) { /* getTraceId() to tag your own logs */ }
}Flow — resilience & control flow
| Decorator | Description |
| -------------------------- | ----------------------------------------------------------------- |
| @Timeout(ms) | reject an async method with TimeoutError if it exceeds ms. |
| @Once | run once per instance; cache that result forever. |
| @Cache(ttlMs, { maxSize?, keyFn? }) | memoize with a TTL (vs @Memoize, which never expires). maxSize bounds memory (LRU); rejected promises aren't cached. |
| @Dedupe | coalesce concurrent identical async calls (single-flight). |
| @Fallback(value\|fn) | on error, return a fallback instead of throwing (sync/async). |
| @RateLimit(limit, ms) | throw RateLimitError past limit calls per rolling ms. |
| @Concurrency(max) | cap concurrent async executions; queue the rest. |
| @CircuitBreaker(opts) | open after N failures, fast-fail with CircuitOpenError, auto-reset. |
| @Debounce(ms) | void methods: collapse rapid calls, run on the trailing edge. |
| @Throttle(ms) | void methods: run on the leading edge, ignore for ms. |
import { Timeout, Retry, CircuitBreaker, Fallback } from '@master4n/decorators';
class Upstream {
@Fallback(null) // OUTERMOST = last resort (see ordering note above)
@CircuitBreaker({ failureThreshold: 5, resetMs: 30_000 })
@Retry(3, { delayMs: 200 })
@Timeout(5_000)
async fetchUser(id: string) { /* ... */ }
}import { GenerateID, Counter, Log, Retry, Memoize } from '@master4n/decorators';
class Job {
@GenerateID id!: string; // unique per instance
@Counter static runs: number; // increments on each read
@Retry(3, { delayMs: 200 })
@Log({ args: true, result: true }) // logged args/result are redacted
async run() { /* ... */ }
@Memoize
score(input: string): number { /* expensive, pure */ return input.length; }
}Breaking change in 2.0.0
@NotNull now throws ValidationError for null/undefined arguments (it
only logged in 1.x). @ValidDate is fixed (it was a no-op) and now throws on an
invalid date. See KNOWN_ISSUES.md for the history.
Changelog
See CHANGELOG.md.
Part of the @master4n toolkit
A small ecosystem of focused, agent-friendly packages:
@master4n/temporal-transformer— epoch/timestamp ↔ date conversion with auto unit-detection and IANA timezones (Luxon-backed)@master4n/temporal-transformer-codemod— codemod to migrate temporal-transformer v1→v2@master4n/http-status— machine-readable HTTP status-code registry for apps & AI agents@master4n/master-cli— headless, JSON-first dev CLI (mfn) for humans and AI agents
Credits
Written by Master4Novice.
