Basic decorator primitives for TypeScript. Writing decorators in TS is hard, this library makes it simpler.
Quick Start • How It Works • Usage • Options • API Reference • Advanced Example
Zero-dependency TypeScript library that provides low-level primitives for creating decorators. Instead of wrestling with property descriptors and prototype traversal, you define a decorator from base hooks:
onInvoke— fired before the method runsonReturn— fired after the method succeedsonError— fired when the method throwsfinally— fired after either success or failure
The library handles method wrapping, this preservation, async/sync support, parameter name extraction, and metadata management so you can focus on your decorator logic.
- Zero dependencies — tiny footprint, no external packages required
- Unified decorator —
Effectworks on both classes and methods - Full async support — promises are handled automatically with
.then,.catch, and.finally - Pre-built args object — arguments are mapped to parameter names and passed into every hook
- Metadata utilities —
SetMeta,getMeta, andsetMetafor symbol-keyed method metadata - TypeScript native — written in TypeScript with full type definitions
npm install base-decoratorsimport { Effect } from 'base-decorators';
class Calculator {
@Effect({
onInvoke: ({ args }) => console.log('add called with', args),
onReturn: ({ result }) => { console.log('result:', result); return result; },
})
add(a: number, b: number) {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3); // logs arguments and resultInstead of creating a custom property descriptor wrapper, you can simply compose decorator hooks to get the desired behavior.
import { Effect } from 'base-decorators';
/** Logs on invoke and return */
const Log = () => Effect({
onInvoke: ({ args }) => console.log('add called with', args),
onReturn: ({ result }) => { console.log('result:', result); return result; },
})
class Calculator {
@Log()
add(a: number, b: number) {
return a + b;
}
}import { OnInvokeHook } from 'base-decorators';
const Validate = () => OnInvokeHook(({ args: [name] }) => {
if (name.startsWith('!')) {
throw new Error('Invalid name: ' + name);
}
});
class Service {
@Validate()
greet(name: string) {
return `Hello, ${name}`;
}
}import { OnErrorHook } from 'base-decorators';
const HandleError = () => OnErrorHook(({ error }) => {
if(error instanceof AxiosError) {
throw new ApiException(error.response?.data);
}
throw error;
});
class Service {
@HandleError()
greet(name: string) {
return `Hello, ${name}`;
}
}import { OnReturnHook } from 'base-decorators';
const ValidateReturn = () => OnReturnHook(({ result }) => {
if (result.length > 10) {
throw new Error('Result is too long');
}
});
class Service {
@ValidateReturn()
greet(name: string) {
return `Hello, ${name}`;
}
}Effect and all hook decorators can be used on both classes and methods out of the box.
import { Effect } from 'base-decorators';
const Log = (message: string) => Effect({
onInvoke: () => console.log(message)
})
// Method-level
class Service {
@Log('doWork invoked')
doWork() { return 42; }
}
// Class-level
@Log('AnotherService invoked') // automatically applied to all methods
class AnotherService {
// will be logged as "AnotherService invoked"
methodA() { return 'a'; }
@Log('methodB invoked') // will be logged as "methodB invoked"
methodB() { return 'b'; }
}A method-level decorator takes precedence over a class-level decorator to avoid duplicated behavior. If you want one type of decorator applied at class level while another is applied at method level, you can pass exclusionKey to the decorator. That way you can create a namespace in which your decorators stay isolated.
const Log = (message: string) => Effect({
onInvoke: () => console.log(message)
});
const Validate = () => Effect({
onInvoke: ({ args }) => {
if (args.length !== 1) {
throw new Error('Invalid arguments');
}
}
});
@Validate()
class Service {
@Log() // only log decorator applied
greet(name: string) {
return `Hello, ${name}`;
}
}const Log = (message: string) => Effect({
onInvoke: () => console.log(message)
});
const Validate = () => Effect({
onInvoke: ({ args }) => {
if (args.length !== 1) {
throw new Error('Invalid arguments');
}
}
}, Symbol('validate'));
@Validate()
class Service {
@Log() // both validate and log decorators applied
greet(name: string) {
return `Hello, ${name}`;
}
}Store and retrieve symbol-keyed metadata on methods:
import { SetMeta, getMeta, setMeta } from 'base-decorators';
const SKIP_ARGS = Symbol('skip');
const SkipArgs = () => SetMeta(SKIP_ARGS, true);
const Log = () => Effect({
onInvoke: ({args, descriptor}) => {
const skipArgs = getMeta<boolean>(SKIP_ARGS, descriptor);
if(skipArgs) {
console.log('function called')
return;
}
console.log('function called with', args)
}
});
@Log()
class Service {
@SkipArgs()
private internal(counter: number) { return 'skip me: ' + counter; }
}Each hook receives a context object. All hooks are optional. Each hook has a corresponding convenience decorator.
| Hook | When it fires | Return value |
|---|---|---|
onInvoke |
Before the original method executes | Ignored |
onReturn |
After the method returns successfully | Replaces the method result |
onError |
When the method throws an error | Replaces the thrown error (return a value or re-throw) |
finally |
After onReturn or onError, regardless of outcome |
Ignored |
interface HookContext {
args: unknown[]; // raw arguments
argsObject: Record<string, unknown> | undefined; // mapped parameter names
target: object; // class instance (this)
propertyKey: string | symbol; // method name
parameterNames: string[]; // extracted parameter names
className: string; // runtime class name
descriptor: PropertyDescriptor; // method descriptor
}For onReturn, the context also includes result. For onError, it includes error.
You can pass an optional exclusionKey symbol to Effect or to any hook decorator. That prevents the same method from being wrapped twice when both class-level and method-level decorators are used. You can also mark methods to skip wrapping with @SetMeta(exclusionKey, true).
const EXCLUDE = Symbol('exclude');
@Effect({ onInvoke: () => console.log('called') }, EXCLUDE)
class Service {
included() { return 'wrapped'; }
@SetMeta(EXCLUDE, true)
excluded() { return 'skipped'; }
}In addition to a static hooks object, Effect accepts a factory function that receives the current HookContext and returns an EffectHooks object. This is useful when you need to decide which hooks (or what behavior) to apply at runtime based on the method being invoked.
import { Effect } from 'base-decorators';
import type { HookContext, EffectHooks } from 'base-decorators';
const DynamicHooks = Effect(({className}: HookContext): EffectHooks => {
if (className === 'DebugService') {
return {
onInvoke: ({ args }) => console.log('debug invoke', args),
onReturn: ({ result }) => result,
};
}
return {
onReturn: ({ result }) => result,
};
});
class DebugService {
@DynamicHooks()
compute(value: number) {
return value * 2;
}
}The factory is called once per method invocation, immediately before onInvoke, so it can inspect args, propertyKey, className, and every other field on HookContext.
| Export | Type | Description |
|---|---|---|
Effect |
Decorator | Unified class+method decorator with lifecycle hooks |
SetMeta |
Decorator | Store metadata on methods |
getMeta |
Function | Retrieve metadata from methods |
setMeta |
Function | Programmatically set metadata on functions |
OnInvokeHook |
Decorator | Convenience hook for onInvoke |
OnReturnHook |
Decorator | Convenience hook for onReturn |
OnErrorHook |
Decorator | Convenience hook for onError |
FinallyHook |
Decorator | Convenience hook for finally |
Build a reusable @Timer decorator that logs how long each method takes, and skip internal helpers with @SetMeta:
import { Effect, SetMeta } from 'base-decorators';
import type { EffectHooks } from 'base-decorators';
const TIMER_KEY = Symbol('timer');
const Timer = (label?: string) => Effect({
onInvoke: ({ className, propertyKey }) => {
const timerLabel = label ?? `${className}.${String(propertyKey)}`;
console.time(timerLabel);
},
finally: ({ className, propertyKey }) => {
const timerLabel = label ?? `${className}.${String(propertyKey)}`;
console.timeEnd(timerLabel);
},
}, TIMER_KEY);
const SkipTimer = () => SetMeta(TIMER_KEY, true);
// Usage on a method
class UserService {
@Timer()
async fetchUser(id: number) {
// fetch logic
return { id, name: 'Alice' };
}
}
// Usage on a class — skip internal helpers
@Timer()
class OrderService {
createOrder() { /* timed */ }
@SkipTimer()
private internalHelper() { /* skipped */ }
}