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

💡 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: 5

2. 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:
//    42

Common Errors (and the fix)

Pro Tips

📚 Keep Learning

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

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

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.

Related lessons