Decorators
Decorators are a way to add annotations and modify classes and their members with the @decorator syntax. They're widely used in frameworks like Angular, NestJS, and TypeORM.
How this lesson runs: these examples use standard TC39 decorators — the version TypeScript 5+ supports by default, matching the ECMAScript proposal, with the
(value, context)signature. This is exactly what the runner executes and whattsc --strictaccepts, with noexperimentalDecoratorsflag required. An older, legacy decorator design used a different(target, key, descriptor)signature and needed that flag plusreflect-metadata; parameter decorators andemitDecoratorMetadatabelong to that legacy world and do not run here. We cover the standard model throughout; the legacy design is noted only as history at the end.
What You'll Learn
By the end of this lesson you will write two working decorators — a method decorator that records every call to the method it wraps, and an accessor decorator factory that rejects an out-of-range assignment before it reaches the field — and wire both onto one class that a suite of checks then holds to account. The recurring question you are learning to answer is what a decorator receives and what it is allowed to hand back, because those two facts determine everything a decorator can do.
This lesson is not tied to a capstone milestone; it is here because the pattern it teaches — wrapping a definition without editing it — is the machinery under a large part of the framework code you will read. You arrive able to write a class with methods, fields and accessors (Classes and OOP) and to constrain a type parameter so a function stays type-transparent (Generics), which is exactly the tool a decorator needs to hand a class back without flattening its type. What is new is that the wrapping happens at definition time, not at call time — the code runs once, while the class is being created, and every later call sees the result.
What Are Decorators?
A decorator is a function that receives the thing being decorated (value) plus a context object describing it (its kind, its name, and hooks like addInitializer). The function can observe or replace what it decorates.
// A class decorator receives (value, context). value is the class itself;
// context.kind is "class" and context.name is the class name.
function logged<T extends new (...args: any[]) => any>(
value: T,
context: ClassDecoratorContext,
): T {
console.log(`Class decorated: ${String(context.name)}`);
return value;
}
@logged
class UserService {
getUser(id: number) {
return { id, name: "Alice" };
}
}
// A decorator FACTORY takes options and returns a decorator — that's what lets
// you write @component({ ... }) with arguments.
function component(options: { name: string; version: string }) {
return function <T extends new (...args: any[]) => any>(
value: T,
context: ClassDecoratorContext,
): T {
console.log(`Registered ${options.name} v${options.version} (${String(context.name)})`);
return value;
};
}
@component({ name: "AppHeader", version: "1.0" })
class AppHeader {
render() {
return "<header>App</header>";
}
}
const svc = new UserService();
console.log(svc.getUser(7));
console.log(new AppHeader().render());
Method Decorators
A method decorator receives the original method as value and can return a replacement function to wrap it. The wrapper runs your code around each call, then delegates to the original with original.apply(this, args).
// A method decorator receives (originalMethod, context). Returning a replacement
// function wraps the method; context.name is the method's name.
function log(
original: (...args: any[]) => any,
context: ClassMethodDecoratorContext,
) {
const name = String(context.name);
return function (this: unknown, ...args: any[]) {
console.log(`Calling ${name}(${args.join(", ")})`);
const result = original.apply(this, args);
console.log(` -> returned: ${JSON.stringify(result)}`);
return result;
};
}
// Each wrapper closes over its own state — here, a call counter.
function count(
original: (...args: any[]) => any,
context: ClassMethodDecoratorContext,
) {
const name = String(context.name);
let calls = 0;
return function (this: unknown, ...args: any[]) {
calls++;
console.log(`${name} call #${calls}`);
return original.apply(this, args);
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
@count
square(n: number): number {
return n * n;
}
}
const calc = new Calculator();
calc.add(3, 4);
console.log(`square(5) = ${calc.square(5)}`);
console.log(`square(9) = ${calc.square(9)}`);
The original.apply(this, args) call is what forwards to the real method with the correct this. Returning a new function from a method decorator is how you add logging, timing, caching, or retry behavior without touching the method body.
That forwarding sentence is easy to read past. The block below is what happens when a wrapper does everything else right and leaves it out.
Debug
The tracked decorator records each call to add on a shared audit log, and the log comes out exactly right — both calls are there. The cart's own arithmetic is right too. But the value the caller receives from the second add is not a number. Commit a hypothesis about what the caller is actually receiving and where it comes from, then fix the decorator so the running total reports 42.
import assert from "node:assert";
const audit: string[] = [];
// A method decorator that records the call, then hands control to the method.
function tracked(
original: (...args: any[]) => any,
context: ClassMethodDecoratorContext,
) {
const name = String(context.name);
return function (this: unknown, ...args: any[]) {
audit.push(name);
original.apply(this, args);
};
}
class Cart {
private total = 0;
@tracked
add(price: number): number {
this.total += price;
return this.total;
}
}
const cart = new Cart();
const afterFirst = cart.add(30);
const afterSecond = cart.add(12);
assert.deepStrictEqual(audit, ["add", "add"], "the audit log should record both calls");
assert.strictEqual(afterSecond, 42, "add should report the running total, got " + afterSecond);
console.log("total:", afterSecond);
console.log("audit:", audit);Expected output:
total: 42 audit: [ 'add', 'add' ]
Accessor and Field Decorators
Standard decorators reach beyond methods. An accessor decorator wraps an auto-accessor's get/set — ideal for validating every assignment. A field decorator returns an initializer transform that runs on the field's initial value and returns the value it should actually start with.
// An accessor decorator wraps the get/set of an `accessor` field. Here it validates
// every assignment against a numeric range, throwing on an out-of-range value.
function range(min: number, max: number) {
return function (
target: ClassAccessorDecoratorTarget<unknown, number>,
context: ClassAccessorDecoratorContext<unknown, number>,
): ClassAccessorDecoratorResult<unknown, number> {
const name = String(context.name);
return {
get() {
return target.get.call(this);
},
set(value: number) {
if (value < min || value > max) {
throw new RangeError(`${name} must be in [${min}, ${max}], got ${value}`);
}
target.set.call(this, value);
},
};
};
}
// A field decorator returns an initializer transform: it receives the field's
// initial value and returns the value the field should actually hold.
function defaultTo(fallback: string) {
return function (_value: undefined, context: ClassFieldDecoratorContext<unknown, string>) {
return function (this: unknown, initial: string): string {
return initial.length > 0 ? initial : fallback;
};
};
}
class Settings {
@range(0, 11) accessor volume: number = 5;
@defaultTo("guest") name: string = "";
}
const s = new Settings();
console.log(`volume: ${s.volume}, name: ${s.name}`); // name fell back to "guest"
s.volume = 9;
console.log(`volume after set: ${s.volume}`);
try {
s.volume = 99;
} catch (e) {
console.log((e as Error).message);
}
Note the accessor keyword on volume — an accessor decorator can only decorate an auto-accessor field, which the compiler backs with a private slot plus a generated getter/setter. The field decorator on name never sees a getter; it just transforms the initial value, which is why an empty name becomes "guest".
Decorator Factories
You have already used factories — range(0, 11) and defaultTo("guest") are decorator factories. A factory is a function that takes configuration and returns the actual decorator, which is what lets you pass arguments at the @ site. Here's one more: a retry factory that wraps a method to re-run it on failure.
// A decorator factory that returns a METHOD decorator: retry wraps the method so
// it re-invokes on failure up to maxAttempts times before rethrowing.
function retry(maxAttempts: number) {
return function (
original: (...args: any[]) => any,
context: ClassMethodDecoratorContext,
) {
const name = String(context.name);
return function (this: unknown, ...args: any[]) {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return original.apply(this, args);
} catch (error) {
lastError = error;
console.log(` ${name} attempt ${attempt}/${maxAttempts} failed: ${(error as Error).message}`);
}
}
throw lastError;
};
};
}
// Why a factory rather than a bare @retry decorator? maxAttempts has to be fixed
// at the @ site, and a bare decorator only ever receives (original, context) —
// there is nowhere to pass it. The extra function layer exists solely to capture
// configuration in a closure the decorator can read.
class ApiClient {
private callCount = 0;
@retry(5)
fetchData(): string {
this.callCount++;
if (this.callCount < 3) {
throw new Error("Network timeout");
}
return "Data received!";
}
}
const client = new ApiClient();
console.log(`Result: ${client.fetchData()}`);
The wrapper closes over maxAttempts and forwards each attempt with original.apply(this, args), so the method's own this.callCount keeps incrementing across retries until the third call succeeds.
Your turn, and this one is not worked for you. Every factory on this page has the same three layers — the factory that takes configuration, the decorator that takes (value, context), and the replacement it returns. Write @timed(label): a method decorator factory that prints label plus the number of calls made so far each time the method runs, then forwards to the original and returns its result. Two of the three layers are already on this page (retry shows the factory-over-method-decorator shape; count shows how a wrapper keeps per-method state in a closure). The piece you have to decide for yourself is where the counter lives — put it in the wrong layer and every decorated method shares one count instead of keeping its own. Predict which layer that is before you write it.
Predict
Two method decorators are stacked on run, and each logs when it applies. The program logs before class, then defines the class, then creates an instance. Decorators run when the class is DEFINED (not when an instance is made), and stacked decorators apply closest-to-the-method first. This runs in the tsx runner. Predict the full output, in order.
function first(_v: any, _c: ClassMethodDecoratorContext) {
console.log("first applies");
}
function second(_v: any, _c: ClassMethodDecoratorContext) {
console.log("second applies");
}
console.log("before class");
class Demo {
@first
@second
run(): void {}
}
console.log("class defined");
new Demo();
console.log("instance created");Recall
Without scrolling up: the class decorator was typed function logged<T extends new (...args: any[]) => any>(value: T, ...): T. That T extends new (...args: any[]) => any constraint is a generic bound you met in the Generics lesson. What does that specific constraint say T must be, and why does returning T (rather than a plain class type) matter for a decorator?
Try It Yourself
You've written class, method, accessor, and field decorators, plus factories. Now combine two into one program: a method decorator that tracks calls and an accessor decorator factory that guards a value (the patterns from Method Decorators and Accessor and Field Decorators). Each decorator is stubbed to a no-op that returns its input unchanged. Run it as-is to see the first failure, implement that decorator, then work down until it prints All checks passed.
Build
Finish the build. Two standard TC39 decorators are stubbed as no-ops (they return their input, so the class still runs but the behavior is missing); the checks below them fail until each decorator does its job. Spec (from Method Decorators and Accessor and Field Decorators above): tracked is a method decorator that returns a wrapper which pushes context.name onto the shared callLog before forwarding to the original with original.apply(this, args), returning its result unchanged. min(n) is an accessor decorator factory whose set rejects any value below n with a thrown RangeError and otherwise forwards to the underlying setter; its get forwards unchanged. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// Standard TC39 decorators: (value, context). Build the two decorators so the
// checks pass. The Account class and the checks are locked — do not edit them.
// TODO 1: a method decorator. Return a wrapper that pushes context.name onto
// callLog, then forwards to the original and returns its result unchanged.
function tracked(
original: (...args: any[]) => any,
context: ClassMethodDecoratorContext,
): (...args: any[]) => any {
return original;
}
// TODO 2: an accessor decorator factory. min(n)'s set must reject any value below
// n with a thrown RangeError, otherwise forward to target.set; get forwards.
function min(n: number) {
return function (
target: ClassAccessorDecoratorTarget<any, number>,
context: ClassAccessorDecoratorContext<any, number>,
): ClassAccessorDecoratorResult<any, number> {
return target;
};
}
const callLog: string[] = [];
class Account {
@min(0) accessor balance: number = 100;
@tracked
deposit(amount: number): number {
this.balance = this.balance + amount;
return this.balance;
}
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const acct = new Account();
assert.strictEqual(acct.deposit(50), 150, "TODO 1: deposit should still return the new balance");
assert.deepStrictEqual(callLog, ["deposit"], "TODO 1: the tracked decorator should log the method name on each call");
acct.deposit(25);
assert.deepStrictEqual(callLog, ["deposit", "deposit"], "TODO 1: each call appends to the log");
assert.strictEqual(acct.balance, 175, "TODO 2: valid sets should pass through the accessor");
assert.throws(() => { acct.balance = -1; }, /RangeError/, "TODO 2: min(0) should reject a negative balance");
assert.strictEqual(acct.balance, 175, "TODO 2: a rejected set must leave the balance unchanged");
console.log("All checks passed.");
console.log("balance:", acct.balance);
console.log("calls:", callLog);Expected output:
All checks passed. balance: 175 calls: [ 'deposit', 'deposit' ]
Once it passes, try two variations and predict each before running:
- Drop the forward in
tracked. Change the wrapper to push the name but not return the call:callLog.push(String(context.name));with noreturn original.apply(this, args);after it. Predict which check fails first before running. The log is still recorded, but the wrapper now returnsundefinedinstead of the method's result, so TODO 1's first check fails first —AssertionError: TODO 1: deposit should still return the new balance,undefined !== 150. A wrapping decorator must forward the return value, or it silently swallows every result. - Log
context.kindinstead ofcontext.name. Intracked, pushString(context.kind)rather thanString(context.name). Predict which check fails first before running.context.kindis"method"for every method, so the log becomes["method"]instead of["deposit"], and TODO 1's second check fails first —deepStrictEqualreports["method"]where["deposit"]was expected.kindtells you what was decorated;nametells you which — the log wanted the name.
Here is the frame the five lines drop into, so the array they record into is not something you have to assume:
const calls: string[] = [];
// the five shuffled lines go here
Arrange the code
This is what a method decorator does, unrolled to the value level and with the class taken away. The frame above supplies the one name the lines do not introduce for themselves: calls, the array the wrapper records into. The five lines below define a wrapper that records a call and forwards to the function it was handed, apply it to a doubling function, invoke the wrapped version, read back the trace, and log both. The lines are shuffled. Put them back in an order that runs - then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?
const wrap = (fn: (n: number) => number) => (n: number): number => { calls.push(`n=${n}`); return fn(n); };const tracked = wrap((n) => n * 2);console.log(`${trace} -> ${result}`);const result = tracked(21);const trace = calls.join(",");
The dependency-injection container below needs no decorators at all — it is plain classes wiring themselves together. It is worth studying because DI is the pattern that decorator-heavy frameworks (Angular, NestJS) build on top of: the @Injectable() decorators you see there are sugar over exactly this kind of registry.
// A simple dependency injection container
class Container {
private services = new Map<string, any>();
register(name: string, factory: () => any): void {
this.services.set(name, factory);
}
resolve<T>(name: string): T {
const factory = this.services.get(name);
if (!factory) throw new Error(`Service '${name}' not registered`);
return factory();
}
}
// Services
class Database {
query(sql: string): string[] {
return [`Result for: ${sql}`];
}
}
class UserRepository {
constructor(private db: Database) {}
findAll(): string[] {
return this.db.query("SELECT * FROM users");
}
}
class UserController {
constructor(private repo: UserRepository) {}
listUsers(): void {
const users = this.repo.findAll();
console.log("Users:", users);
}
}
// Wire up dependencies
const container = new Container();
container.register("database", () => new Database());
container.register("userRepo", () => new UserRepository(container.resolve("database")));
container.register("userController", () => new UserController(container.resolve("userRepo")));
const controller = container.resolve<UserController>("userController");
controller.listUsers();
A Note on Legacy Decorators
Everything above uses standard TC39 decorators — the (value, context) signature TypeScript 5+ compiles for you. The proposal is still working its way through TC39, so JavaScript engines don't run decorator syntax natively yet — your build step transforms it. You will still encounter an older, incompatible design in existing codebases and framework docs. Legacy decorators use a different signature and require the experimentalDecorators compiler flag; they are shown here only for recognition, and this fence is no-run because the runner does not execute the legacy form:
// LEGACY decorator (needs "experimentalDecorators": true in tsconfig).
// Signature is (target, propertyKey, descriptor) — NOT the standard (value, context).
function legacyLog(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`(legacy) calling ${key}`);
return original.apply(this, args);
};
return descriptor;
}
class OldStyle {
@legacyLog
greet(name: string): string {
return `Hi ${name}`;
}
}
Two legacy-only features have no standard equivalent that runs here: parameter decorators (decorating a constructor argument) and emitDecoratorMetadata with the reflect-metadata library (which frameworks like older NestJS use for dependency injection by reading parameter types at runtime). Both depend on the legacy design and its flags, so neither runs in this sandbox. When you migrate a codebase from legacy to standard decorators, those are the two features you must replace with explicit code — the rest maps cleanly onto the (value, context) model you learned above.
Transfer
Python has decorators too, written with the same @ sigil, and Rust has attribute macros written #[derive(Debug)] or #[test]. All three attach a name above a definition and change what that definition becomes. The surfaces differ sharply — Python's decorator is an ordinary function that runs when the module loads, and Rust's attribute macro runs inside the compiler and never exists at run time. What is the invariant that carries across all three, and what changed?
Key Takeaways
- Standard TC39 decorators are functions with a
(value, context)signature — noexperimentalDecoratorsflag required contextcarries the decorated member'skindandname, plus hooks likeaddInitializer- A method decorator can return a replacement function to wrap the original, forwarding with
original.apply(this, args) - Accessor decorators wrap an
accessorfield's get/set (ideal for validation); field decorators return an initializer transform - Decorator factories take configuration and return the decorator, enabling
@decorator(args)syntax - Stacked decorators apply bottom-to-top (closest to the member first) at class-definition time
- A separate legacy decorator design (
(target, key, descriptor),experimentalDecorators, parameter decorators,emitDecoratorMetadata) still exists in older code but does not run under the standard model
Pro Tip: Use decorators for cross-cutting concerns that span many classes: logging, caching, authentication checks, validation. Keep business logic in the methods themselves. If a decorator does too much, split it into multiple focused decorators that compose well.
Next Steps
You've added behavior to classes with standard decorators, wrapping methods and fields at definition time. Next, you'll take the type system out of the class and into the UI layer — applying it to the most popular frontend framework: typing React components, props, hooks, events, and context for a fully type-safe frontend.
Ready to continue? Head to React and TypeScript!
Next lesson
React and TypeScript
Type React components, props, hooks, events, and context with TypeScript. Catch UI bugs at compile time and build self-documenting code.
25 min