TypeScript Decorators

September 22, 20269 min read
Requirements:
FunctionsObjectsGenerics

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 decoratorsLegacy decorators
SinceTypeScript 5.0TypeScript 1.5
Configworks out of the box"experimentalDecorators": true
Method signature(value, context)(prototype, key, descriptor)
Parameter decoratorsnot supportedsupported
emitDecoratorMetadatanoyes
Futurematches the JavaScript proposalfrozen

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 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] ?? {}).role

This 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:

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.

Share this article

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

Practice with Challenges

Put your typescript decorators knowledge to the test with these related challenges.

#2828ClassPublicKeys
Hard
#3312Parameters
Easy
#2Get Return Type
Medium

Related Concepts

Concepts that build on or relate to typescript decorators.

TypeScript ClassesTypeScript Function TypesTypeScript GenericsInterfaces