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.
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 |
Real, copy-paste compositions. Each replaces a page of hand-written plumbing with a stack of declarations.
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.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.
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, ... }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 builderimport { 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.
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"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.
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.
| 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 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]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. { 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; // " A@B.CO " -> "a@b.co", 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
}| 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). |
| 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 builderBuild 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). |
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.
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. |
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). |
| 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 */ }
}| 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; }
}@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.
See CHANGELOG.md.
Written by Master4Novice.