TypeScript Decorators Introduction
A decorator is a special kind of declaration you attach to a class, method, accessor, or field using the @expression syntax. Decorators let you observe, wrap, or completely replace the thing they decorate at the moment the class is defined, without touching the class’s own source code. They’re how libraries like Angular, NestJS, and TypeORM add behavior — registering a component, wiring up dependency injection, mapping a database column — on top of plain classes, and TypeScript 5.0 made a standardized version of them a core part of the language.
Overview / How it works
Structurally, a decorator is just a function. When the compiler sees @myDecorator above a class member, it evaluates myDecorator and calls it with information about the thing being decorated. Depending on what you return, the class member is left alone, wrapped, or swapped out entirely. This all happens once, when the class body is evaluated — not once per instance. If you decorate a method, the decorator function runs a single time (when the class is defined) to produce the final method; that final method is what every instance then shares on the prototype.
Since TypeScript 5.0, decorators implement the TC39 Stage 3 ECMAScript decorators proposal by default — you do not need to set experimentalDecorators in tsconfig.json to use them, and the ambient types that describe them (ClassDecoratorContext, ClassMethodDecoratorContext, and friends) are built into the compiler automatically. This is a different, incompatible system from the older "legacy" decorators (enabled via experimentalDecorators: true) that frameworks like Angular and NestJS still use for historical reasons — the two have different call signatures, so code written for one will not type-check against the other. This lesson covers the modern, standard decorators that ship by default.
Every decorator receives two things: the target (the class, method, or accessor being decorated) and a context object describing it — its kind ("class", "method", "getter", "setter", "accessor", or "field"), its name, and whether it’s static or private. The decorator can return a replacement value (a new class, a new method, a new { get, set } pair) or return nothing (void) to leave the member as-is.
Syntax
The general shapes look like this:
@decorator
class MyClass {
@decorator
method() {}
@decorator
accessor field: string;
@decorator
plainField: number;
}
@decorator— a bare decorator;decoratormust evaluate to a function matching the expected shape for whatever it decorates.@decorator(args)— a decorator factory: a function call that itself returns the actual decorator function. Use this whenever the decorator needs configuration.accessor field: Type;— theaccessorkeyword turns a field into an auto-generated getter/setter pair backed by a private slot, which is what lets a decorator intercept reads and writes.
| Decorates | context.kind |
Target parameter type | Can return |
|---|---|---|---|
| Class | "class" |
the class constructor | a new constructor, or nothing |
| Method | "method" |
the method function | a replacement function, or nothing |
| Getter / Setter | "getter" / "setter" |
the getter/setter function | a replacement function, or nothing |
| Auto-accessor | "accessor" |
{ get, set } object |
a new { get, set } object, or nothing |
| Field | "field" |
(none — use context.addInitializer) |
an initializer function, or nothing |
Examples
Example 1: A class decorator that logs construction
Class decorators receive the constructor itself. Because the standard allows a class decorator to return a new class, you can subclass the original to add behavior around every instantiation:
function loggedClass<Class extends new (...args: any[]) => any>(
target: Class,
context: ClassDecoratorContext
) {
return class extends target {
constructor(...args: any[]) {
super(...args);
console.log(
`constructing an instance of ${target.name} with arguments ${args.join(", ")}`
);
}
};
}
@loggedClass
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
const p = new Person("Ray");
Output:
constructing an instance of Person with arguments Ray
loggedClass runs exactly once, when TypeScript evaluates the class Person declaration. It returns a brand-new anonymous class that extends Person, and that returned class becomes the actual value bound to the name Person. Every subsequent new Person(...) call goes through the decorated constructor.
Example 2: A method decorator that wraps a function
Method decorators receive the method itself as a plain function and must return a replacement function with a matching signature:
function loggedMethod<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const methodName = String(context.name);
function replacementMethod(this: This, ...args: Args): Return {
console.log(`LOG: Entering method '${methodName}'.`);
const result = target.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`);
return result;
}
return replacementMethod;
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
p.greet();
Output:
LOG: Entering method 'greet'.
Hello, my name is Ray.
LOG: Exiting method 'greet'.
The generic parameters This, Args, and Return let loggedMethod apply to a method of any signature while still type-checking the wrapped call — target.call(this, ...args) is fully typed, and the replacement method’s signature must match the original exactly.
Example 3: An accessor decorator that logs reads and writes
The accessor keyword turns a field into a getter/setter pair, which an accessor decorator can intercept:
function logged<This, Value>(
target: ClassAccessorDecoratorTarget<This, Value>,
context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
const name = String(context.name);
return {
get(this: This): Value {
const value = target.get.call(this);
console.log(`getting ${name} => ${String(value)}`);
return value;
},
set(this: This, newValue: Value): void {
console.log(`setting ${name} => ${String(newValue)}`);
target.set.call(this, newValue);
},
};
}
class Person {
@logged accessor name: string;
constructor(name: string) {
this.name = name;
}
}
const person = new Person("Ada");
person.name = "Grace";
console.log(person.name);
Output:
setting name => Ada
setting name => Grace
getting name => Grace
Grace
The constructor’s this.name = name already goes through the decorated setter, which is why "setting name => Ada" appears before any explicit assignment outside the constructor.
Under the hood
A few things are worth internalizing about how decorators actually execute:
Evaluation order. Decorator expressions on a single class are evaluated top-to-bottom in source order, but the resulting functions are applied bottom-to-top (innermost/closest to the member first) — the same "onion" ordering used for function composition. With a single decorator per member, as in the examples above, you don’t need to worry about this, but it matters once you stack multiple decorators on the same method.
Per-class, not per-instance. The decorator function itself runs once, at class-definition time. Any behavior that needs to happen for every instance has to live inside the function or object the decorator returns (the replacement method, or the get/set functions), or be registered via context.addInitializer, which schedules a callback to run once per instance during construction.
Types are fully erased at runtime. After compilation, none of the type annotations — This, Args, ClassMethodDecoratorContext<...>, and so on — exist in the emitted JavaScript. What remains is plain function calls: the compiled output calls your decorator function with the real method and a real (runtime) context object, and substitutes whatever it returns. The type system’s only job is to make sure those calls line up correctly before you ever run the code.
Common Mistakes
Mistake 1: Applying a decorator to the wrong kind of member
A decorator’s parameter types are tied to a specific context.kind. Writing a method decorator and attaching it to a plain field mismatches both the target type (a function vs. a field’s value) and the context type:
function loggedMethod<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
return target;
}
class Counter {
@loggedMethod
count: number = 0;
}
Here count is a field, so TypeScript passes a ClassFieldDecoratorContext and a value of type number — neither matches the function-shaped target or the ClassMethodDecoratorContext that loggedMethod expects. tsc reports a type error along the lines of "this context type is not assignable", because a field decorator and a method decorator are simply not interchangeable. The fix is to decorate an actual method, or write a decorator whose context type is generic over context.kind if it truly needs to support both.
Mistake 2: Forgetting to call a decorator factory
A decorator factory is a function that returns a decorator — it must be called with parentheses before the @ syntax can use it:
function minLength(min: number) {
return function <This, Value extends string>(
target: ClassAccessorDecoratorTarget<This, Value>,
context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
return {
get(this: This): Value {
return target.get.call(this);
},
set(this: This, value: Value): void {
if (value.length < min) {
throw new RangeError(`must be at least ${min} characters`);
}
target.set.call(this, value);
},
};
};
}
class Account {
@minLength accessor username: string;
constructor(username: string) {
this.username = username;
}
}
Written this way, @minLength treats minLength itself — a (min: number) => ... function — as the decorator, so TypeScript tries to pass the accessor’s target where a number is expected and reports a type error. The fix is to call the factory with its arguments so the result of that call becomes the decorator:
function minLength(min: number) {
return function <This, Value extends string>(
target: ClassAccessorDecoratorTarget<This, Value>,
context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
return {
get(this: This): Value {
return target.get.call(this);
},
set(this: This, value: Value): void {
if (value.length < min) {
throw new RangeError(`must be at least ${min} characters`);
}
target.set.call(this, value);
},
};
};
}
class Account {
@minLength(3) accessor username: string;
constructor(username: string) {
this.username = username;
}
}
const account = new Account("bob");
console.log(account.username);
Output:
bob
Now minLength(3) is evaluated first and its return value — the actual accessor decorator — is what gets applied to username.
Best Practices
- Prefer the modern (stage 3) decorator syntax for new code — it needs no
experimentalDecoratorsflag and is what the type system checks by default. - Before adding decorators to a project that already uses Angular, NestJS, TypeORM, or a similar framework, check whether it relies on legacy (
experimentalDecorators) decorators — the two systems have incompatible signatures and cannot be mixed on the same declaration. - Type your decorators with generics (
This,Args,Return,Value) instead ofanyso callers still get accurate type-checking and autocomplete on the decorated member. - Keep the decorator function itself free of per-instance side effects; push per-instance work into the returned method, the returned
get/setfunctions, orcontext.addInitializer. - Remember a decorator runs once per class definition, not once per instance — don’t reach for a decorator when you really just need constructor logic.
- Use a decorator factory (a function returning a decorator) any time the decorator needs configuration, and always call it with parentheses, even for zero arguments, e.g.
@retry().
Practice Exercises
- Write a class decorator named
frozenthat callsObject.freezeon both the class’s constructor and itsprototyperight after the class is defined. Apply it to a smallConfigclass with a couple of properties. - Write a method decorator named
oncethat wraps a method so that only its first call actually runs the original method body; every later call should return the cached result from that first call instead of re-running the body. - Write an accessor decorator factory named
clamp(min: number, max: number)that decorates a numericaccessorfield so that any value set outside[min, max]is clamped into range before being stored.
Summary
- Decorators are functions invoked once, at class-definition time, that can observe, wrap, or replace a class, method, accessor, or field.
- TypeScript 5.0+ implements the TC39 stage-3 decorators proposal by default — no
experimentalDecoratorsflag is needed, and this is a different system from the older "legacy" decorators used by frameworks like Angular. - Every decorator receives a target (constructor, method, or
{ get, set }pair) and a context object describingkind,name,static, andprivate. - A decorator can return a replacement value to swap in new behavior, or return nothing to leave the member unchanged.
- Decorator factories (functions that return a decorator) are how you pass configuration, and must always be called with parentheses.
- All decorator types are erased at compile time — the emitted JavaScript is just ordinary function calls and substitutions.
