TypeScript Decorators
TypeScript decorators are functions that run at class-definition time and get a chance to observe or replace what they are attached to. A method decorator can wrap the method, a field decorator can transform its initial value, a class decorator can swap the whole class for another one.
There is one thing you need to know before reading any other tutorial on this: there are two different decorator systems, and most of the code you will find online is written for the older one. TypeScript 5.0 shipped the TC39 standard decorators with a different signature than the flag-gated version that came before it. If a snippet does not compile for you, it is almost always this.
The two kinds of TypeScript decorators
| Standard decorators | Legacy decorators | |
|---|---|---|
| Since | TypeScript 5.0 | TypeScript 1.5 |
| Config | works out of the box | "experimentalDecorators": true |
| Method signature | (value, context) | (prototype, key, descriptor) |
| Parameter decorators | not supported | supported |
emitDecoratorMetadata | no | yes |
| Future | matches the JavaScript proposal | frozen |
Standard decorators are the ones to learn. They are on track to become plain JavaScript, so the same code will eventually run without a compile step. Everything below uses them unless it says otherwise.
A method decorator, start to finish
A standard method decorator takes the method and the context, and returns a replacement method (or nothing, if it only wants to watch).
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const label = String(context.name)
return function (this: This, ...args: Args): Return {
console.log(`→ ${label}`, args)
const result = target.call(this, ...args)
console.log(`← ${label}`, result)
return result
}
}
class PaymentService {
@logged
charge(cents: number) {
return { ok: true, cents }
}
}The three type parameters are what keep this reusable. This carries the instance type, Args
the parameter tuple, Return the result — so the wrapper has the same
function type as the method it replaces and callers see no
difference. Writing a decorator without generics is possible, but you pay
for it with any at every call site.
Note what the decorator does not get: an instance. Decorators run once, when the class is defined, not once per object.
The context object
The second argument is where the useful information lives. Every decorator kind gets a slightly
different shape, described by an interface like
ClassMethodDecoratorContext, but the fields are consistent:
kind—'class','method','getter','setter','field'or'accessor'name— the member name, astringorsymbolstaticandprivate— booleans, so you can reject what you do not supportaccess— aget/setpair that reads the member on an instance, private ones includedaddInitializer— registers a callback that runs per instance during constructionmetadata— a shared object attached to the class
kind is worth leaning on. A field decorator applied to a method is a mistake TypeScript catches
for you, because the context types do not match.
access is the one that surprises people. It hands you a closure over the member, including a
#private one, which means a decorator can read state it has no syntactic access to. That is
deliberate: the decorator was written inside the same module as the class, so it is allowed to
look. It does not leak — you only get the accessor if you hold the context object.
Class decorators
A class decorator receives the constructor. Return nothing to use it for a side effect:
const jobRegistry = new Map<string, unknown>()
function registered(target: unknown, context: ClassDecoratorContext) {
jobRegistry.set(String(context.name), target)
}
@registered
class EmailJob {
run() {
return 'sent'
}
}Or return a different class to replace the original. This is how a singleton is usually written:
function singleton<T extends new (...args: any[]) => object>(
target: T,
_context: ClassDecoratorContext
): T {
let instance: object | undefined
return class extends target {
constructor(...args: any[]) {
if (instance) return instance
super(...args)
instance = this
}
} as T
}
@singleton
class AppConfig {
readonly createdAt = Date.now()
}Replacement is the sharpest tool here and the easiest to misuse. The returned class has to stay
assignable to the original, and anything reading the class name or prototype chain now sees
your anonymous subclass. Reach for addInitializer first if all you need is a side effect on
construction.
Field decorators transform the initial value
Field decorators get undefined as their value — the field does not exist yet — and return a
function that receives the initial value and returns the one to actually use. That return
function runs for every instance.
Since a decorator is just a function, a function that returns a decorator gives you arguments. These are called decorator factories, and they are the common case in real code:
function minLength(limit: number) {
return function (_value: undefined, context: ClassFieldDecoratorContext<unknown, string>) {
return (initial: string) => {
if (initial.length < limit) {
throw new Error(`${String(context.name)} needs ${limit} characters`)
}
return initial
}
}
}
class SignupForm {
@minLength(8) password = 'hunter2!'
}✅ @minLength(8) — call it, and the decorator is what comes back
❌ @minLength — passes the factory itself, and the signature will not match
The second generic parameter on ClassFieldDecoratorContext is the field's type. Declaring it as
string means TypeScript rejects @minLength(8) on a number field at compile time, which is
the whole point of typing your own decorators instead of taking any.
Auto-accessors
accessor is a class field modifier that creates a getter and setter over a hidden private
field. It exists mostly so decorators have something uniform to hook into:
function observed<Value>(
target: ClassAccessorDecoratorTarget<unknown, Value>,
context: ClassAccessorDecoratorContext<unknown, Value>
): ClassAccessorDecoratorResult<unknown, Value> {
return {
get() {
return target.get.call(this)
},
set(next: Value) {
console.log(`${String(context.name)} ->`, next)
target.set.call(this, next)
},
init(initial: Value) {
return initial
},
}
}
class Ticket {
@observed accessor status = 'open'
}You can override get, set, init, or any subset. This is the pattern behind every reactive
state library that uses decorators — intercept the setter, notify subscribers.
Binding this without a constructor
addInitializer runs per instance, which makes the classic detached-method problem a one-liner:
function bound<This, Args extends unknown[], Return>(
value: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
const instance = this as unknown as Record<string | symbol, unknown>
instance[context.name] = value.bind(this)
})
}
class Button {
label = 'ok'
@bound
click() {
return this.label
}
}Pass new Button().click to addEventListener and it keeps working. Without @bound it returns
undefined, because this is the element by then. Class methods live on the prototype and lose
this the moment you detach them — see TypeScript classes for the
longer version.
Evaluation order
Two things happen at different times, and mixing them up produces real bugs:
function traceOrder(tag: string) {
console.log(`evaluated ${tag}`)
return function <T extends (...args: never[]) => unknown>(
value: T,
_context: ClassMethodDecoratorContext
): T {
console.log(`applied ${tag}`)
return value
}
}
class OrderDemo {
@traceOrder('outer')
@traceOrder('inner')
run() {
return 1
}
}That logs evaluated outer, evaluated inner, applied inner, applied outer. Decorator
expressions are evaluated top to bottom; the decorators themselves are applied bottom up, so the
one closest to the declaration wraps first and ends up innermost. Member decorators all run
before the class decorators.
Metadata
context.metadata is a plain object shared by every decorator on a class, and it lands on the
class as Symbol.metadata:
function tagged(role: string) {
return function (_value: unknown, context: ClassDecoratorContext) {
context.metadata.role = role
}
}
@tagged('service')
class Mailer {}
const mailerRole = (Mailer[Symbol.metadata] ?? {}).roleThis is how you replace the old reflect-metadata dependency, with one caveat: Symbol.metadata
is still a proposal, so most runtimes need a polyfill (Symbol.metadata ??= Symbol('metadata')
is enough). Reading it back is untyped, so cast or validate before you trust it.
When a decorator is the wrong tool
Decorators are indirection. The behaviour of charge() is no longer in charge(), and a reader
has to find @logged to know what actually happens. That is a real cost, and it buys you
something only when the same concern repeats across many members.
Two limits decide most cases for you. First, a decorator cannot change the type of what it decorates. A replacement method must stay assignable to the original signature, so you cannot use one to add a parameter or narrow a return type — type-level reshaping is the job of mapped types and the built-in utility types. Second, a decorator cannot see arguments until the wrapper runs, and it never sees module-level state, so validation that depends on configuration usually reads better as an explicit call.
The honest rule: use a decorator when the concern is cross-cutting and uniform — logging, registration, binding, observable state. Write a plain higher-order function when it applies in one or two places. A function you pass around is easier to test, easier to type, and shows up in a search for the thing it does.
What standard decorators still cannot do
Three gaps, and they are the reason experimentalDecorators has not disappeared:
- No parameter decorators.
constructor(@inject private db: Db)is legacy-only. This is the big one for dependency-injection frameworks, and it is why Angular, NestJS and TypeORM still ask for the legacy flag. - No
emitDecoratorMetadata. That flag emits design-time type information for legacy decorators only. The standardmetadataobject is not a replacement — you write the values yourself. - Not on
declarefields, overloads, or.d.tsdeclarations.
You cannot mix the two systems in one project either. Turning on experimentalDecorators turns
standard decorators off everywhere. If you depend on a framework that needs the flag, stay on
legacy and wait; otherwise use the standard ones. The
TypeScript handbook documents
both signatures, and the
TC39 proposal is the source of truth for where
standard decorators are heading.
Summary
TypeScript decorators come in two incompatible flavours: learn the standard (value, context)
form and treat anything with (target, key, descriptor) as legacy. Decorators run once at
class-definition time; addInitializer is what gets you per-instance behaviour. Return a value
to replace the member, return nothing to observe it. Use factories to take arguments, type the
context generics so your decorator only accepts members it can handle, and remember the split
between top-down evaluation and bottom-up application. Parameter decorators are the one real
feature the new system does not have yet.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges