Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate TypeScript 5.0+ ~23 min· topic 8 of 8

Topic 6.8

Decorators

In one line

A decorator is a function written as @name above a class or one of its members. It runs once when the class is defined and can watch, wrap or replace what it decorates. TypeScript 5.0 supports the standard ECMAScript decorators, and the older experimentalDecorators still powers Angular and NestJS.

Think of it like this

Gift wrapping. You hand over a plain book. The wrapper does not change the book: it adds paper, a ribbon, maybe a card, and the person still gets "the book", only dressed. A decorator wraps a class or a method the same way. The method inside is still the same method; the decorator adds logging, timing, a lock, a default value around it. You put the wrapper on with one line (@logged) instead of editing the book.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Decorator
A function applied with @name to a class or member, run once when the class is defined.
Context object
The second argument of a standard decorator: describes the member (kind, name, static) and offers addInitializer.
Initializer
Code that runs for a field or for each new instance; decorators can register or replace it.
Cross-cutting concern
Behaviour needed in many places that is not the main job, like logging or timing.
Standard decorators
The ECMAScript decorator design, supported by TypeScript 5.0 without any flag.
Legacy decorators
The older experimentalDecorators design with different function signatures.
Metadata
Extra data about types attached for libraries to read at runtime (emitDecoratorMetadata, legacy only).
Wrapper
A new function that calls the original and adds behaviour before or after.

Step by step

01A decorator is a function that gets the thing and a context

@logged above add calls logged(add, context) once, when the class is defined. logged returns a new function that logs, calls the original with target.call(this, ...args), logs the result and returns it. The class now has the wrapped version.

The generics This, Args, Return copy the method's own signature, so add is still (a: number, b: number) => number afterwards.

logged.tswhole filets
function logged<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log("calling", name, "with", args);
    const result = target.call(this, ...args);
    console.log(name, "returned", result);
    return result;
  };
}

class Calc {
  @logged
  add(a: number, b: number) {
    return a + b;
  }
}

02One decorator for each kind of thing

A class decorator receives the class and can seal it or return a subclass. A field decorator returns a function that turns the starting value into the final one. An accessor decorator can replace get, set and init. The context's kind tells them apart.

One decorator for each kind of thingdiagram
Rendering diagram…

03Evaluated top down, applied bottom up

@tag("first") above @tag("second"): the factory calls run in reading order, but the *decorators* are applied from the one nearest the member outwards. The console shows apply second before apply first.

04addInitializer: do something for every new object

A method decorator can call context.addInitializer(fn). The function runs for each new instance with this set to it. A @bound decorator uses this to replace the method with a version tied to the instance, so const { click } = new Button() still works (compare the detached-method problem in Topic 6.1).

05The old system looks different

Legacy decorators get (target, key, descriptor) and need "experimentalDecorators": true. Angular and NestJS use them, together with emitDecoratorMetadata. Mixing them up gives a clear error.

tsconfig.jsonwhole filejson
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

06A legacy decorator is not a standard decorator

A function with the legacy three parameters can't be used as a standard method decorator. TypeScript explains the arity mismatch.

legacy.tswhole filets
function legacyLog(target: any, key: string, descriptor: PropertyDescriptor) {}
class Svc {
  @legacyLog   // error TS1241: The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.
  run(n: number) {
    return n * 2;
  }
}

Try it yourself

  1. 1

    Time the call

    Change logged so it also prints how many arguments were passed (args.length). Predict the first console line before you run.

  2. 2

    Clamp a different range

    In the Volume example change @clamp(0, 10) to @clamp(1, 3). What do the two console lines print?

    3 and 1: the initial value 5 is clamped by init to 3 as well, then 50 gives 3 and -3 gives 1.

  3. 3

    Swap the order

    In the order example swap the two @tag lines. Predict the console lines (the labels swap places).

Code & diagrams

A method decorator that logs TypeScript 5.0+

The decorator ran once when the class was defined. After that, every call to add goes through the wrapper. The generics keep the original signature.

Console

(nothing was printed)

Expected console output

calling add with [ 2, 3 ]
add returned 5
5
A class decorator TypeScript 5.0+

The first line is printed as soon as the class is defined, before any object exists. kind is "class" and name is "Config". The decorator returned nothing, so it only modified the class.

Console

(nothing was printed)

Expected console output

decorating class Config
dev
true
A field decorator that supplies a default TypeScript 5.0+

defaultTo(8080) is a decorator factory: a function that returns the decorator. The returned initializer receives the field's starting value. port has none, so it gets 8080. retries starts at 0, which is not undefined, so it stays 0.

Console

(nothing was printed)

Expected console output

decorating port field
decorating retries field
8080 0
An accessor decorator that clamps a value TypeScript 5.0+

The accessor keyword gave the decorator a getter and a setter to wrap. Writing 50 stores 10, writing -3 stores 0.

Console

(nothing was printed)

Expected console output

10
0
Order: evaluated top down, applied bottom up TypeScript 5.0+

The decorators were applied when the class was defined, before run() was ever called. The one nearest the method is applied first.

Console

(nothing was printed)

Expected console output

apply second to run
apply first to run
done
addInitializer: binding a method to its instance TypeScript 5.0+

Compare Topic 6.1: a detached method used to lose this. The initializer replaced click on each new instance with a bound copy, so the detached call works.

Console

(nothing was printed)

Expected console output

OK
Legacy (experimental) decorators

This example turns on experimentalDecorators, the option Angular and NestJS rely on. Same idea, different signature: (target, key, descriptor) instead of (value, context). Without the option TypeScript rejects this decorator.

Console

(nothing was printed)

Expected console output

legacy call run [ 4 ]
8

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use a legacy decorator in a standard project

Use the legacy.ts snippet from the walkthrough: a function legacyLog(target, key, descriptor) applied as @legacyLog on a method.

terminal
$ Types panel
── what you'll see ──
index.ts(3,3): error TS1241: Unable to resolve signature of method decorator when called as an expression.
The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.

Myth vs fact

Myth

Decorators run every time a method is called.

Fact

The decorator itself runs once, when the class is defined. If it returns a wrapper, the wrapper runs on every call.

Myth

Decorators need experimentalDecorators.

Fact

That flag turns on the old design. Standard decorators (TypeScript 5.0) need no flag, and mixing the two does not work.

Myth

A decorator can see the constructor parameters like Angular's injection does.

Fact

Parameter decorators exist only in the legacy system. Standard decorators cover classes, methods, accessors and fields.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Standard decorators are a stage 3 ECMAScript proposal. Engines don't run @ syntax natively yet, so TypeScript lowers it to helper functions (__esDecorate, __runInitializers) at emit time. That is why the examples here work in the browser, and why your build tool must support the TypeScript emit (or a compatible transform), not just type-stripping.

  • ▸

    Decorating a field can't reach the instance during decoration (this is not available), only inside the returned initializer. To change how a field reads and writes, use the accessor keyword (Topic 6.6) and an accessor decorator.

  • ▸

    The old and the new system disagree on parameter lists, evaluation details and metadata (emitDecoratorMetadata is legacy-only). Libraries are migrating at different speeds, so check what each dependency expects before turning experimentalDecorators on or off.

  • ▸

    Typing decorators is the part that takes practice. Capture the method's this, its argument list and its return type with generics (This, Args extends unknown[], Return) so the wrapped method keeps its signature, and choose the context type that matches the kind you decorate.

Remember this

  1. 1

    The problem this solves: Extra behaviour such as logging or checking would otherwise be copied into every method that needs it, which buries the real logic. A decorator is just a function. You apply it by writing @decoratorName on the line above a class, method, getter, setter, field or accessor. It runs once, when the class is defined (not each time an object is created or a method is called). It receives the thing being decorated and a context object, and it may return a replacement. So @logged above add means roughly add = logged(add, context).

  2. 2

    TypeScript 5.0 implemented the standard ECMAScript decorators (an ECMAScript proposal that was at stage 3 when TypeScript 5.0 shipped it; engines do not run the @ syntax natively, so check current support before relying on it). TypeScript lowers them into ordinary JavaScript while compiling, which is why they run in the live editor even though the browser has no decorator syntax. You don't need any compiler flag. This is a different system from the old one (below), with different function signatures.

  3. 3

    The context object describes what is being decorated. Its kind is "class", "method", "getter", "setter", "field" or "accessor"; name is the member's name; static and private say what sort of member it is; access lets you read and write the value; metadata is a shared object for the class; and addInitializer(fn) registers code to run later (when the class is finished, or for each new instance). TypeScript gives each kind a context type: ClassMethodDecoratorContext, ClassFieldDecoratorContext, ClassDecoratorContext, ClassAccessorDecoratorContext and so on, which you write in your decorator's parameter list so the checker knows what's allowed.

  4. 4

    What each kind receives and may return. A method decorator gets the original function and may return a replacement function (the logged example wraps it). A class decorator gets the class and may return a subclass (or nothing, as with sealed, which only modifies it). A field decorator gets undefined (fields have no value yet) and may return an initializer function that receives the starting value and returns the final one. An accessor decorator gets the original get and set and may return a new get, set and init; this is why the accessor keyword (Topic 6.6) exists: a plain field has no getter or setter to wrap.

  5. 5

    Order of events. With several decorators on one member, they are *evaluated* top to bottom (the expressions after @) but *applied* bottom to top, like wrapping a gift: the paper goes on before the ribbon. In the example, @tag("second") runs first and @tag("first") wraps last. For a whole class, member decorators run first, then the class decorator.

  6. 6

    Writing a typed method decorator takes generics, because the decorator should work for any method: <This, Args extends unknown[], Return>(target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext<This, ...>). The type parameters capture the method's this, its argument list and its return type, so the wrapper has the same signature as the original. Copy this shape; it's the same pattern every logging or timing decorator uses.

  7. 7

    Legacy decorators. Before the standard existed, TypeScript had experimentalDecorators (a compiler option), with a different shape: a method decorator gets (target, key, descriptor), there are parameter decorators (used for constructor injection), and emitDecoratorMetadata adds runtime type information. Angular and NestJS are built on this system and keep requiring those options in their tsconfig (check your framework's current guide, since they are moving over time). The two systems are incompatible: a legacy-style decorator function used in a project without experimentalDecorators fails to compile (TS1241, with the line The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.). Standard decorators have no parameter decorators.

  8. 8

    Use decorators for cross-cutting concerns: logging, timing, caching, access checks, binding this, registering a class with a framework. Do not hide business logic in them, because magic that changes behaviour from a distance is hard to follow and debug. Remember they run at definition time, so any side effect in the decorator body happens when the file loads.

Explain it without notes

01

What is a decorator, and when does it run?

02

What does the context object contain and what is addInitializer for?

03

How do standard decorators differ from experimentalDecorators?

04

In which order are several decorators on one method evaluated and applied?

Practice

01

Write a method decorator timed that logs "done" after the method returns, keeping the method's signature. Apply it to a method that returns 42 and print the result.

02

Write a class decorator registered that pushes the class name into a names: string[] array when the class is defined. Apply it to two classes and print the array.

Trade-offs

  • ↔

    Decorators remove repeated plumbing (logging, caching, registration) from the code that matters, but they change behaviour out of sight, so overuse makes code hard to follow.

  • ↔

    Standard decorators are the future and need no flags; legacy ones are what many frameworks still demand today. You may need to understand both.

Done when you can

  • I can write a typed method decorator that wraps a method with generics.

  • I can say when a decorator runs and what the wrapper it returns does.

  • I can name what a class, method, field and accessor decorator receive and may return.

  • I can explain the order of several decorators on one member.

  • I can tell standard decorators from experimentalDecorators and explain TS1241.