TypeScript Class Decorators
A class decorator is a function you attach to a class declaration with @decoratorName syntax. It runs exactly once, at the moment the class itself is defined (not when instances are created), and it can inspect the class, register it somewhere, or even replace it entirely with a new class. Class decorators are how TypeScript lets you add cross-cutting behavior — logging, validation, sealing, registration, dependency injection — without cluttering the class body itself.
This lesson focuses on the modern, standards-based decorators that TypeScript 5.0+ supports natively, with no extra compiler flags required.
Overview: How Class Decorators Work
Historically, TypeScript shipped an experimental decorator implementation (enabled via the experimentalDecorators compiler option) based on an early TC39 proposal. That flavor is still used by frameworks like Angular and NestJS together with the reflect-metadata package. Since TypeScript 5.0, the compiler also implements the current Stage 3 ECMAScript decorators proposal — a real, standardized JavaScript feature. This is the default when you write @decorator above a class and you have not set experimentalDecorators, and it is what modern TypeScript code should use going forward. This lesson uses that modern API exclusively.
A class decorator is just a function with a specific shape. When you write:
@myDecorator
class Widget { }
The compiler calls myDecorator(Widget, context) immediately after the class body has been fully evaluated, where:
- The first argument is the class itself — the constructor function, not an instance.
- The second argument is a
contextobject describing the class: itskind(always the literal"class"for a class decorator), itsname, and anaddInitializermethod for scheduling extra setup.
The decorator can then do one of two things: return nothing (undefined/void), leaving the original class untouched, or return a brand-new constructor, which then replaces the original binding everywhere — every subsequent new Widget(), every instanceof Widget check, and every import of Widget from that module will refer to the replacement.
An important nuance: decorators are not erased
Most TypeScript-only syntax — type annotations, interfaces, generic parameters, as assertions — disappears completely when compiled to JavaScript; it exists purely to help the type checker and has zero runtime footprint. Class decorators are different: they are real, standardized JavaScript behavior, not a type-system feature. The @myDecorator application and the decorator function’s logic remain in the compiled output and execute every time the module loads that class declaration. What gets erased is only the type annotations inside the decorator’s signature (target: T, context: ClassDecoratorContext) — the call itself is not optional and not stripped.
Syntax
@decoratorExpression
class ClassName {
// class members
}
| Part | Meaning |
|---|---|
decoratorExpression |
A decorator function, or a call to a decorator factory (a function that returns a decorator function) when you need to pass configuration. |
target (1st param) |
The class constructor being decorated. Often typed as Function, or as a generic T extends new (...args: any[]) => object when you need to extend it. |
context (2nd param) |
A ClassDecoratorContext object: { kind: "class", name: string | undefined, addInitializer(fn), metadata }. |
| Return value | void to leave the class unchanged, or a new constructor to replace it. |
Multiple decorators can be stacked above a single class, one per line. Their expressions are evaluated top to bottom, but they are applied bottom to top — see the third example below.
Examples
Example 1: Logging construction with a mixin-style decorator
This is the classic pattern for a decorator that wraps a class: declare it generically over T so you can safely write class extends target and keep every original member and constructor parameter.
type Constructor = new (...args: any[]) => object;
function logged<T extends Constructor>(target: T, context: ClassDecoratorContext) {
return class extends target {
constructor(...args: any[]) {
super(...args);
console.log(`Created a new ${context.name} instance`);
}
};
}
@logged
class Product {
constructor(public name: string, public price: number) {}
}
const p = new Product("Keyboard", 49.99);
console.log(p.name, p.price);
Output:
Created a new Product instance
Keyboard 49.99
logged receives Product as target and returns a brand-new anonymous class that extends it, adding a constructor that logs before delegating to super(...args). Because the replacement class extends target, it keeps every property, method, and the original constructor’s parameter list — instanceof Product still works, since the returned class’s prototype chain includes the original.
Example 2: A parameterized decorator factory (sealing a class)
When a decorator needs configuration, write a plain function that returns the decorator — this is a decorator factory. This example follows the standard pattern of typing the target as Function, which is enough when you only need to call methods like Object.freeze and don’t need to extend it.
function sealed(message: string) {
return function (target: Function, context: ClassDecoratorContext) {
console.log(message);
Object.freeze(target);
Object.freeze(target.prototype);
};
}
@sealed("Sealing the Account class")
class Account {
balance = 0;
constructor(public owner: string) {}
}
const acc = new Account("Ada");
console.log(acc.owner, acc.balance);
Output:
Sealing the Account class
Ada 0
sealed("...") is called first and returns the actual decorator function, which the compiler then applies to Account. Object.freeze on the constructor and its prototype prevents anyone from adding new static members or reassigning methods later — but it does not stop normal instantiation, because each new Account(...) call still creates a fresh, unfrozen instance object.
Example 3: Auto-registering classes with addInitializer
A very realistic use of class decorators is building a registry — think of a UI component library, a router, or a plugin system that needs to know about every decorated class.
type Constructor = new (...args: any[]) => object;
const registry = new Map<string, Constructor>();
function registerComponent<T extends Constructor>(target: T, context: ClassDecoratorContext) {
const className = context.name ?? "Anonymous";
context.addInitializer(function () {
console.log(`${className} is ready`);
});
registry.set(className, target);
return target;
}
@registerComponent
class Button {
constructor(public label: string) {}
}
@registerComponent
class Checkbox {
constructor(public checked: boolean) {}
}
console.log([...registry.keys()]);
const ButtonClass = registry.get("Button");
if (ButtonClass) {
const instance = new ButtonClass("Submit") as Button;
console.log(instance.label);
}
Output:
Button is ready
Checkbox is ready
[ 'Button', 'Checkbox' ]
Submit
context.addInitializer schedules a callback that runs right after the class is fully finalized — useful for side effects that must happen once the whole class definition (including any static members) is settled, rather than in the middle of the decorator’s own logic. The registry itself is just a plain Map, showing that class decorators are ordinary runtime code with full access to closures and outside state.
Under the Hood: Step by Step
- TypeScript first evaluates the entire class body — fields, methods, static members — producing the base constructor function, exactly as if no decorator were present.
- Each decorator attached to the class is then called with
(currentClass, context). If several decorators are stacked, they are applied bottom-to-top: the one closest to theclasskeyword runs first, and its result (if any) is fed into the next one up. - If a decorator returns a new constructor, that value becomes the class going forward — every later reference to the class name in that scope (including
new ClassName(), exports, andinstanceof) resolves to the replacement. - Once all class decorators have been applied and a final constructor is settled, any callbacks registered with
context.addInitializerrun synchronously, in the order they were registered. - Only then does execution continue to the next statement after the class declaration.
At compile time, the type annotations on the decorator’s parameters (: T, : ClassDecoratorContext) and any generics are stripped away, exactly like everywhere else in TypeScript. What survives into the emitted JavaScript is the decorator call itself and its logic, because — unlike types — decorators are a genuine runtime language feature, not a compile-time-only annotation.
Common Mistakes
Mistake 1: Forgetting to return the replacement class
If your decorator builds a wrapper class but never returns it, TypeScript does not report an error — a decorator is allowed to return void. The bug is silent and purely logical.
function addTimestamp<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
class Wrapped extends target {
createdAt = new Date().toISOString();
}
// BUG: forgot to `return Wrapped;`
}
@addTimestamp
class Order {
constructor(public id: number) {}
}
const o = new Order(1);
console.log((o as any).createdAt);
Output:
undefined
Wrapped is built but discarded, so Order is completely unaffected — and because a missing return just makes the function’s inferred return type void, which is a perfectly legal decorator return type, tsc has no reason to complain. The fix is to actually return the wrapper:
function addTimestamp<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
return class extends target {
createdAt = new Date().toISOString();
};
}
@addTimestamp
class Order {
constructor(public id: number) {}
}
const o = new Order(1);
console.log(typeof (o as any).createdAt);
Output:
string
Mistake 2: Returning a class that doesn’t match the original shape
Unlike Mistake 1, this one is caught by the compiler. If your decorator returns a completely unrelated class instead of extending target, the return value no longer satisfies the generic type T that was inferred from the decorated class, and tsc rejects it.
function broken<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
return class {
extra = true;
};
}
@broken
class Widget {
constructor(public id: number) {}
}
tsc reports an error at the @broken line, essentially: the object returned ({ new(): { extra: boolean } }) is not assignable to T, because T was inferred as typeof Widget — a class with a different constructor parameter list and different members. The fix is to make the replacement genuinely compatible by extending target:
function fixed<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
return class extends target {
extra = true;
};
}
@fixed
class Widget {
constructor(public id: number) {}
}
const w = new Widget(5);
console.log(w.id, (w as any).extra);
Output:
5 true
Mistake 3: Assuming stacked decorators apply top-to-bottom
When you stack several decorators, it’s tempting to assume the one written first runs first. In fact they are applied in the opposite order — bottom to top, closest to the class first.
function first(target: Function, context: ClassDecoratorContext) {
console.log("first: applied");
}
function second(target: Function, context: ClassDecoratorContext) {
console.log("second: applied");
}
@first
@second
class Widget {}
new Widget();
Output:
second: applied
first: applied
Even though @first is written above @second, second — the one closest to the class keyword — is called first, and its result feeds into first. This mirrors function composition, first(second(Widget)), and matches the order used by the legacy decorator implementation too, so it’s worth memorizing rather than re-deriving each time.
Best Practices
- Prefer the modern, context-based decorator API (as used throughout this lesson) for new code — it needs no
experimentalDecoratorsflag and is the actual ECMAScript standard. Reserve the legacy API for frameworks that still require it. - Decide explicitly whether your decorator mutates in place (
return void) or replaces the class (returna new constructor), and make that clear from the decorator’s name — callers relying oninstanceofor static members need to know. - When replacing a class, extend
targetrather than building an unrelated class, so the prototype chain,instanceofchecks, and inherited members keep working. - Type decorator parameters generically (
<T extends new (...args: any[]) => object>) instead ofany, so consumers of the decorated class keep full type safety and autocompletion. - Use
context.addInitializerfor setup that must run only after the entire class (including static members) is finalized, rather than burying it in the middle of the decorator body. - Remember decorators run once, when the class is declared — not once per instance. Don’t reach for a class decorator when you actually need per-instance logic; that belongs in the constructor or a method decorator.
- Name decorator factories as verbs or adjectives (
@sealed,@registerComponent,@withVersion) so@decoratorNameabove a class reads naturally.
Practice Exercises
- Exercise 1: Write a class decorator
frozenthat freezes both the decorated class and its prototype usingObject.freeze, similar to thesealedexample. Apply it to a small class and confirm (by reasoning about the code, or trying it yourself) that assigning a new static property to the class afterward would throw in strict mode. - Exercise 2: Write a decorator factory
withVersion(v: string)that adds a staticversionproperty equal tovto any class it decorates, while preserving the original class’s constructor and instance behavior. Hint: return a class that extendstargetand declaresstatic version = v;. - Exercise 3: Write a class decorator
trackInstancesthat keeps a running count of how many instances of the decorated class have been created, exposed as a staticcountproperty. Hint: wrap the constructor in a subclass and increment a counter (declared via a static field on the replacement class) inside it. Create three instances and confirmcountequals3.
Summary
- Class decorators are functions applied with
@decoratorNameabove a class; they run once, when the class is declared, not per instance. - A decorator receives the class (
target) and acontextobject (kind,name,addInitializer,metadata). - Returning
voidleaves the class unchanged; returning a new constructor replaces the class everywhere it’s referenced. - Decorators are real runtime JavaScript, not a type-only construct — only their type annotations are erased at compile time, not their behavior.
- Stacked decorators evaluate top-to-bottom but apply bottom-to-top, like function composition.
- Use decorator factories (a function returning a decorator) whenever you need to pass configuration into the decorator.
- Prefer the modern, flag-free context-based decorator API for new TypeScript code; the legacy
experimentalDecoratorsAPI remains only for compatibility with older frameworks.
