Decorators
Reviewed & published by Brayan K
A decorator is a function applied with @ syntax to a class, method, or field that can observe or replace it — fundamentally a higher-order function that takes the decorated thing and returns an enhanced version.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- See a decorator as a plain higher-order function
- Write a @log method decorator that wraps a call
- Lock a field with a @readonly decorator
- Add statics to a class with a class decorator
- Build decorator factories like @withVersion('3.1')
- Tell stage-3 decorators from experimentalDecorators
💡 Real-World Analogy
A decorator is gift-wrapping. You hand over a present (a method or class) and get back the same present in nicer packaging — maybe with a ribbon that logs who opened it, or a tamper-proof seal that makes it read-only. The gift still works as before; the wrapper just adds behaviour around it. A decorator factory is choosing the wrapping paper first: withVersion("3.1") picks the design, then wraps.
1. A Decorator Is a Higher-Order Function
Strip away the @ syntax and a method decorator is just a function that receives the original method and returns a replacement. A @log decorator wraps the method so every call is logged, then delegates to the original. The TS 5 form adds a context argument, but the mechanic is the same wrapping you can do by hand.
// TS 5 / stage-3 method decorator. The decorator receives the original method
// plus a context object, and returns a replacement function:
function log(originalMethod: any, _ctx: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
console.log("calling with:", args);
const result = originalMethod.apply(this, args);
console.log("returned:", result);
return result;
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
new Calculator().add(2, 3); // logs the call and the result, returns 5// A DECORATOR is, at heart, a higher-order function: it takes something (a method,
// a class) and returns a wrapped/enhanced version. TS 5 stage-3 decorators are
// special syntax for this, but the IDEA is plain JS. Here is a @log as a HOF.
function log(originalMethod) {
// Return a replacement that wraps the original with logging:
return function (...args) {
console.log("calling with: " + JSON.stringify(args));
const result = originalMethod.apply(this, args);
console.log("returned: " + result);
return result;
};
}
// "Applying the decorator" by hand — wrap the function:
let add = (a, b) => a + b;
add = log(add);
add(2, 3);
// calling with: [2,3]
// returned: 52. A @readonly Field Decorator
A field decorator returns an initializer that runs per instance, so it can transform or lock a field. A @readonly decorator's runtime job is to make the property non-writable — under the hood, Object.defineProperty with writable: false. That's the same primitive whether you write the decorator or the defineProperty call yourself.
// A stage-3 field decorator returns an initializer that runs per instance:
function readonly<T>(_value: undefined, ctx: ClassFieldDecoratorContext) {
return function (this: any, initial: T): T {
Object.defineProperty(this, ctx.name, {
value: initial,
writable: false, // locks the field after construction
enumerable: true,
});
return initial;
};
}
class AppConfig {
@readonly version = "1.0.0";
}
const c = new AppConfig();
// c.version = "2.0.0"; // throws in strict mode — the field is frozen// A @readonly property decorator's runtime job is to lock a property. In TS 5
// you'd write @readonly on a field; the underlying mechanic is
// Object.defineProperty with writable:false. Here's that mechanic directly.
function makeReadonly(obj, key, value) {
Object.defineProperty(obj, key, {
value,
writable: false, // this is what "readonly" enforces at runtime
enumerable: true,
configurable: false,
});
return obj;
}
const config = {};
makeReadonly(config, "version", "1.0.0");
console.log(config.version); // 1.0.0
config.version = "2.0.0"; // ignored (non-strict) — the write is blocked
console.log(config.version); // 1.0.0 (unchanged)3. Class Decorators & Factories
A class decorator receives the constructor and can augment or replace the whole class — adding statics, sealing it, registering it. When you need to pass options, use a decorator factory: @withVersion("3.1") is a function that returns the actual decorator. Note this is TS 5 stage-3 syntax — not the legacy experimentalDecorators that Angular and NestJS still rely on.
// A class decorator factory: withVersion("3.1") RETURNS the decorator.
function withVersion(version: string) {
return function <T extends new (...args: any[]) => any>(
Target: T,
_ctx: ClassDecoratorContext,
): T {
(Target as any).version = version; // add a static
return Target;
};
}
@withVersion("3.1")
class Widget {
constructor(public name: string) {}
}
// (Widget as any).version === "3.1"
// IMPORTANT: TS 5 stage-3 decorators are NOT the old "experimentalDecorators".
// Frameworks like Angular and NestJS still use the LEGACY decorators (which need
// "experimentalDecorators": true and emitDecoratorMetadata in tsconfig).// A CLASS decorator receives the class (a constructor) and can return a
// replacement. In TS 5: @sealed class Foo {}. The runtime mechanic is just
// wrapping/subclassing the constructor. A FACTORY returns the actual decorator.
function withVersion(version) {
// A decorator FACTORY: returns the real decorator (which takes the class).
return function (TargetClass) {
TargetClass.version = version; // attach a static property to the class
return TargetClass; // return the (possibly wrapped) class
};
}
// Apply by hand: decorate the class with the factory's result.
let Widget = class {
constructor(name) { this.name = name; }
};
Widget = withVersion("3.1")(Widget);
console.log(Widget.version); // 3.1 (static added by the decorator)
const w = new Widget("button");
console.log(w.name); // button🎯 Your Turn
Write a @double decorator as a higher-order function — it wraps a method so its result is doubled. Fill in the two blanks marked ___, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Write a @double decorator AS A HIGHER-ORDER FUNCTION: it wraps a method so its
// result is multiplied by 2. (TS 5 would write @double; the idea is the same HOF.)
function double(originalMethod) {
return function (...args) {
return originalMethod.apply(this, args) * ___; // 👉 multiply the result by 2
};
}
let getValue = () => 21;
getValue = ___(getValue); // 👉 apply the 'double' decorator to getValue
console.log(getValue()); // 42
// ✅ Expected output:
// 42Common Errors (and the fix)
- ❌ "Decorators are not valid here" — you applied one to something unsupported, or mixed decorator modes. ✅ Use TS 5+ stage-3 decorators on classes/members; don't combine with the experimental flag.
- ❌ Framework decorators stop working — Angular/Nest need the legacy design. ✅ Keep "experimentalDecorators": true and emitDecoratorMetadata for those projects.
- ❌ Factory used without calling it — @withVersion instead of @withVersion("3.1"). ✅ A factory must be called; a plain decorator must not.
- ❌ Lost this in a wrapped method — you called the original without preserving context. ✅ Use originalMethod.apply(this, args) inside the wrapper.
Pro Tips
- 💡 Think "higher-order function" first. Every decorator is one — that lens makes them easy to write and read.
- 💡 Use the context object (ctx.name, ctx.kind) in stage-3 decorators to know what you're decorating.
- 💡 Reach for factories when a decorator needs configuration, like a route path or a cache size.
- 💡 Check the target framework's docs before assuming which decorator mode applies — Angular/Nest differ from vanilla TS 5.
📚 Keep Learning
- 📖 TS 5.0 Release Notes — Decorators
- 📖 Handbook — Decorators (legacy / experimental)
- 📖 tsconfig — experimentalDecorators
Mini-Challenge: A @memoize Decorator
No blanks this time — just a brief and a starting outline. Build the caching decorator as a higher-order function, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: a @memoize decorator as a higher-order function.
// (TS 5: @memoize on a method. Here: a HOF that adds a cache.)
// 1. memoize(fn) keeps a Map cache keyed by the argument 'n'.
// 2. On a cache HIT return "cached:" + cachedResult.
// On a MISS compute fn(n), store it, and return "computed:" + result.
// 3. Wrap square = (n) => n * n with memoize, then call
// square(4), square(4) again, and square(5).
//
// ✅ Expected output:
// computed:16
// cached:16
// computed:25
// your code here🎉 Lesson Complete
- ✅ A decorator is a higher-order function applied with @ syntax
- ✅ A method decorator wraps a method (e.g. @log) and delegates to the original
- ✅ A field decorator can lock a property (e.g. @readonly via defineProperty)
- ✅ A class decorator augments the constructor; a factory returns the decorator
- ✅ TS 5 stage-3 decorators differ from legacy experimentalDecorators (Angular/Nest)
Practice quiz
At its core, what is a decorator?
- A CSS class
- A runtime polyfill
- A higher-order function that receives the decorated thing and can return an enhanced version
- A type alias
Answer: A higher-order function that receives the decorated thing and can return an enhanced version. Every decorator is fundamentally a higher-order function applied with @ syntax.
Do TypeScript 5 / stage-3 decorators require the 'experimentalDecorators' tsconfig flag?
- No — stage-3 (TC39) decorators need no special flag
- Yes, always
- Only for class decorators
- Only in strict mode
Answer: No — stage-3 (TC39) decorators need no special flag. TS 5's stage-3 decorators follow the finalized TC39 proposal and work with no flag.
What extra argument do stage-3 decorators receive that the old experimental ones did not?
- A prototype object
- The tsconfig
- A reflect-metadata handle
- A context object (e.g. ctx.name, ctx.kind)
Answer: A context object (e.g. ctx.name, ctx.kind). Stage-3 decorators receive a context object describing what is being decorated.
Why do Angular and NestJS still use the legacy experimentalDecorators?
- They are written in JavaScript
- They rely on emitDecoratorMetadata for dependency injection
- Stage-3 decorators are slower
- They predate TypeScript
Answer: They rely on emitDecoratorMetadata for dependency injection. Those frameworks depend on emitDecoratorMetadata (reflect-metadata) for DI, which stage-3 does not emit.
A @log method decorator works by:
- Returning a replacement function that wraps and delegates to the original
- Deleting the method
- Renaming the method
- Freezing the class
Answer: Returning a replacement function that wraps and delegates to the original. A method decorator returns a wrapper that adds behaviour and calls the original method.
What runtime primitive does a @readonly field decorator use to lock a property?
- Object.freeze on the class
- delete obj[key]
- Object.defineProperty with writable: false
- A WeakMap
Answer: Object.defineProperty with writable: false. The runtime job is Object.defineProperty(..., { writable: false }) to make the property non-writable.
A decorator FACTORY like @withVersion("3.1") is:
- A bare decorator
- A function that returns the actual decorator
- A class
- An interface
Answer: A function that returns the actual decorator. A factory is called first; it returns the real decorator, which then receives the target.
What is the difference between @sealed and @withVersion("3.1")?
- No difference
- @sealed is a factory
- @withVersion cannot take arguments
- @sealed IS the decorator (no parens); @withVersion("3.1") is a factory that must be called
Answer: @sealed IS the decorator (no parens); @withVersion("3.1") is a factory that must be called. A plain decorator has no parentheses; a factory must be invoked to produce the decorator.
Inside a wrapper, how do you preserve the original method's 'this'?
- Ignore this
- Use originalMethod.apply(this, args)
- Use a global variable
- Bind to window
Answer: Use originalMethod.apply(this, args). Call originalMethod.apply(this, args) so the wrapped method keeps its receiver.
What can a class decorator do?
- Only rename the class
- Only add CSS
- Receive the constructor and augment or replace the whole class (e.g. add statics)
- Nothing at runtime
Answer: Receive the constructor and augment or replace the whole class (e.g. add statics). A class decorator receives the constructor and can add statics, seal it, register it, or replace it.
Continue this course
- Previous: Advanced Utility Types
- Next: Migrating JavaScript to TypeScript — Adopt TS incrementally with allowJs, JSDoc, @ts-check, and gradual strict
- Quick reference: TypeScript cheat sheet
Frequently asked questions
What is a decorator in TypeScript 5?
A decorator is a function applied to a class, method, accessor, field, or getter/setter with the @ syntax, letting you observe or replace it. At its core a decorator is a higher-order function: it receives the thing being decorated (and a context object) and can return an enhanced version. TypeScript 5 implements the TC39 stage-3 decorators standard, which works without any special tsconfig flag.
How are stage-3 decorators different from experimentalDecorators?
The old decorators were an experimental, pre-standard design enabled by 'experimentalDecorators': true (often with emitDecoratorMetadata). TypeScript 5's stage-3 decorators follow the finalized TC39 proposal, have a different signature (they receive a context object), and need no flag. The two are not interchangeable — code written for one generally won't run under the other.
Why do Angular and NestJS still use the legacy decorators?
Those frameworks were built on the experimental decorator design and rely heavily on emitDecoratorMetadata for dependency injection (reading parameter types at runtime via reflect-metadata). The stage-3 standard doesn't emit that metadata, so until the frameworks migrate, they keep 'experimentalDecorators': true. Always check a framework's docs for which decorator mode it expects.
What can each decorator kind do?
A class decorator receives the constructor and can replace or augment the whole class (e.g. add statics, seal it). A method decorator can wrap the method to add logging, timing, or access checks. A field decorator returns an initializer that runs per instance, so it can transform or lock the field's value. Accessor and getter/setter decorators wrap property access similarly.