cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

Decorators in TypeScript

Write and type TypeScript 5 decorators for logging, timing, bound click handlers, and checks, and see how they differ from Angular's experimentalDecorators.

By the end, you can
  • 01
    Write standard decoratorsWrite method, field, accessor, and class decorators, and decorator factories, for logging, timing, binding, and simple checks.
  • 02
    Type them so TypeScript checks their useUse the context types, like ClassMethodDecoratorContext, so a decorator in the wrong place, or on the wrong kind of method, gets an error.
  • 03
    Tell the two kinds apartExplain how experimentalDecorators, used by Angular and NestJS, differs, and why emitDecoratorMetadata only works with it.

A function that wraps a method

A cart class has an addItem method. While you debug, you want a line in the console every time it's called. You could add a console.log to addItem, and then to every other method you want to watch. Or you could write the logging once, as a decorator, and put it on any method with an @ sign:

A decorator that logs each callTypeScript
function logged(method: (this: Cart, item: string) => number, context: ClassMethodDecoratorContext) {  const name = String(context.name);  return function (this: Cart, item: string) {    console.log(`${name}("${item}")`);    return method.call(this, item);  };} class Cart {  items: string[] = [];   @logged  addItem(item: string) {    this.items.push(item);    return this.items.length;  }} const cart = new Cart();console.log(cart.addItem("Tea"));

Lines 1 to 7 are the decorator, an ordinary function called logged. Line 12, @logged, puts it on addItem. When JavaScript defines the class, it calls logged once, with two inputs:

  • method, the addItem function you wrote on lines 13 to 16.
  • context, an object that describes it. Line 2 reads its name, "addItem". Its type, ClassMethodDecoratorContext, comes with TypeScript.

Line 3 returns a new function, a wrapper. The class keeps the wrapper in place of addItem. So when line 20 calls cart.addItem("Tea"), the wrapper runs: line 4 prints addItem("Tea"), and line 5 calls the original with the same this and item (call is explained in call, apply, and bind). The original returns 1, and line 20 prints 1.

The addItem method goes into a window called logged, which gets addItem and returns a wrapper. The class keeps the new addItem, which logs, then calls the original. All of this happens once, while JavaScript defines the class.once, while the class is definedaddItemthe method you wroteloggedgets addItem,returns a wrappernew addItemlogs, then callsthe original
logged runs once, while the class is being defined. It gets addItem and returns a wrapper. The class keeps the wrapper, so every cart.addItem(…) logs first, then runs the original.
Why decorator examples don't run here

The editor on this page removes types and runs the rest in your browser. It doesn't compile the @ syntax, and browsers such as Chrome don't run it yet. So every example with a decorator is type-checked on the page as you read it, but has no Edit & run. The lesson's tests compile each one with the TypeScript compiler, run the result, and check that it prints what the lesson says. In a project, tsc or your build tool compiles decorators into plain JavaScript first.

Real-life analogyA receptionist at the front desk

An office hires a receptionist on the day it opens. From then on, every visitor stops at the front desk, signs the book, and then goes on to the same office as before. Nobody had to change the office itself.

In real life: The office down the hall
In TypeScript: The method you wrote, addItem
In real life: A receptionist signs in every visitor, then sends them on
In TypeScript: The wrapper logs each call, then calls addItem
In real life: The receptionist is hired once, when the office opens
In TypeScript: logged runs once, when the class is defined
In real life: Visitors still reach the same office
In TypeScript: The original method still does the work
The main idea

A decorator is a function you put on a class or one of its members with @. JavaScript calls it once, while it defines the class, with the member and a context that describes it. Whatever it gives back takes the member's place. TypeScript checks that the decorator fits the member you put it on.

Decorators are a JavaScript feature that is still being added to the language. TypeScript 5.0 and later supports them with no setting. The JavaScript decorators lesson covers what they do when your code runs; this lesson is about typing them, and about the older kind Angular uses.

What a decorator receives

Every standard decorator gets the same two inputs, (value, context). value is the thing being decorated. context describes it. Here is a decorator that prints what it gets:

A decorator that reads its contextTypeScript
function describe(value: unknown, context: ClassMethodDecoratorContext) {  const kind = context.kind;  //    ^?  const name = context.name;  //    ^?  console.log(`${kind} ${String(name)}, static: ${context.static}, private: ${context.private}`);} class Cart {  @describe checkout() {}}

Line 1 types context as a ClassMethodDecoratorContext. The query on line 3 shows that context.kind has the type "method": a literal type, exactly that one string. The query on line 5 shows name is a string | symbol, because a method can be named with a symbol. That's why the examples wrap it in String(). Line 10 decorates checkout, and the code prints method checkout, static: false, private: false.

describe(value, context). value is the checkout method. context is an object holding kind: "method", name: "checkout", static: false and private: false, and addInitializer.describe(value, context)valuecheckoutthe method itselfcontextkind: "method"name: "checkout"static: false, private: falseaddInitializer(…)
For @describe checkout(), value is the method itself, and context describes it: its kind, its name, whether it's static or private, and tools like addInitializer.

Each kind of class member gets its own context type, and TypeScript picks the one JavaScript will pass. Read each row of the table as: put a decorator here, it gets this context, and this value.

The context type for each place
You put it onIts context typeWhat value is, and what it can give back
A class: @register("cart-badge") class CartBadgeClassDecoratorContextThe class itself. It can give back a new class to use instead.
A method: @logged addItem()ClassMethodDecoratorContextThe method. It can give back a function to use instead.
A getter or a setter: @logged get size()ClassGetterDecoratorContext or ClassSetterDecoratorContextThe getter or setter. It can give back one to use instead.
A field: @trimmed query = ""ClassFieldDecoratorContextundefined, because the field has no value yet. It can give back a function that changes the starting value.
An auto-accessor: @positive accessor quantity = 1ClassAccessorDecoratorContextAn object with get and set. It can give back new get, set, and init functions.

Because each place has its own context type, TypeScript can tell when a decorator is in the wrong place. Move @logged around the class below:

Playground: where can @logged go?
A method decorator, moved aroundTypeScript
function logged(value: unknown, context: ClassMethodDecoratorContext) {  console.log(`logged ${context.kind} ${String(context.name)}`);} class Cart {  items: string[] = [];  @logged checkout() { return this.items.length; }  get size() { return this.items.length; }}
Compiled and runlogged method checkout
What happens
@logged is online 7
TypeScriptNo errors
Try it yourself

checkout is a method, so JavaScript gives logged a ClassMethodDecoratorContext, exactly the type line 1 asks for. No errors. Compiled and run, it prints logged method checkout.

TypeScript checks each version for real. Each button moves @logged to one line, and the other lines stay put. The output is what the code prints once tsc compiles it, even with an error. Reset goes back to the method.

Only On the method has no errors. The other three get a message that starts Unable to resolve signature of … decorator when called as an expression. In plain words: "TypeScript tried the call JavaScript will make here, with this member's value and context, and it doesn't fit your decorator's parameters." The message's next line names the part that doesn't fit, such as a ClassFieldDecoratorContext where a ClassMethodDecoratorContext goes. The output shows the code still runs once compiled: each time, context.kind tells the truth about where the decorator really is.

Typing a method decorator

The first logged only worked for Cart methods that take one string. To use one decorator on any method, make it generic, with a type parameter for each part of the method:

A logged decorator for any methodTypeScript
function logged<This, Args extends unknown[], Return>(  method: (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(`${name}(${args.join(", ")})`);    return method.call(this, ...args);  };} class Cart {  total = 0;   @logged  add(price: number, quantity: number) {    this.total += price * quantity;    return this.total;  }} const total = new Cart().add(3, 2);//    ^?console.log(total);

Lines 1 to 4 name three blanks that TypeScript fills in from the method it decorates:

  • This: the object the method runs on. For add, it's Cart.
  • Args: the list of parameter types, here [price: number, quantity: number].
  • Return: what the method gives back, here number.

Line 3 passes the same blanks to ClassMethodDecoratorContext<This, …>, so the context matches the method. Line 6 says the wrapper takes the same arguments and returns the same type as the original. That's why the query on line 23 still shows const total: number: the decorator didn't lose the method's types. Line 7 prints add(3, 2), and line 24 prints 6.

A typed decorator can also say which methods it accepts. This one only fits methods that return a number:

A decorator for number methods only (an error on purpose)TypeScript
function rounded<This, Args extends unknown[]>(  method: (this: This, ...args: Args) => number,  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => number>,) {  return function (this: This, ...args: Args) {    return Math.round(method.call(this, ...args));  };} class Cart {  @rounded total() { return 7.6; }  @rounded label() { return "Cart"; }} console.log(new Cart().total());

Line 2 says the method must return a number, and line 6 rounds it. Line 11 is fine: total returns 7.6, and line 15 prints 8. Line 12 puts @rounded on label, which returns text. TypeScript marks it twice, on purpose:

  • Unable to resolve signature of method decorator when called as an expression, whose next line says Argument of type '() => string' is not assignable to parameter of type '(this: Cart) => number': the method doesn't return a number.
  • Decorator function return type … is not assignable: the number-returning wrapper can't replace a method that returns text.

The same shape works for timing. A timing decorator notes the time before and after the call, with performance.now(), which gives the time in milliseconds:

A decorator that times a methodTypeScript
function timed<This, Args extends unknown[], Return>(  method: (this: This, ...args: Args) => Return,  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,) {  return function (this: This, ...args: Args): Return {    const start = performance.now();    const result = method.call(this, ...args);    console.log(`${String(context.name)} took ${Math.round(performance.now() - start)}ms`);    return result;  };} class Cart {  items = ["Tea", "Cake"];   @timed  saveCart() {    return JSON.stringify(this.items);  }} console.log(new Cart().saveCart());

Line 6 notes the start time, line 7 runs the original method, and line 8 prints how long it took. Saving two items is quick, so it prints something like saveCart took 0ms. Line 9 hands back the result, and line 22 prints ["Tea","Cake"].

@bound for event handlers

A method you pass as an event handler loses its object: button.addEventListener("click", cart.handleClick) calls handleClick with a different this (this in TypeScript covers why). A @bound decorator fixes it once, for every object. Turn it off and on:

Playground: a click handler with and without @bound
A cart button's click handlerTypeScript
function bound<This extends object, Args extends unknown[], Return>(  method: (this: This, ...args: Args) => Return,  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,) {  context.addInitializer(function () {    Reflect.set(this, context.name, method.bind(this));  });} class CartButton {  count = 0;   @bound  handleClick() {    this.count += 1;    console.log(`Clicked ${this.count} times`);  }} const button = new CartButton();const onClick = button.handleClick;onClick();onClick();
Compiled and run
Clicked 1 times
Clicked 2 times
Try it yourself

Line 13's @bound asks for setup work when each button object is made: store a copy of handleClick bound to that object. So onClick on line 21 still knows its object, and each call counts up.

TypeScript checks both versions for real, and the output is what each prints once tsc compiles it. Only line 13 changes. Reset turns @bound back on.

Lines 1 to 8 are bound. It returns nothing, so the method stays as it is. Instead, line 5 calls context.addInitializer, which asks for setup work to run each time a new object is made. Line 6 is that work: it stores a copy of the method made with bind, which fixes this to the new object. Reflect.set(this, context.name, …) sets the property named by context.name, like this.handleClick = … when the name is in a variable.

Line 21 copies button.handleClick into onClick, as passing it to addEventListener would. With @bound, lines 22 and 23 print Clicked 1 times and Clicked 2 times. Without it, line 22 throws TypeError: Cannot read properties of undefined (reading 'count'), and TypeScript doesn't warn you.

You can stack decorators on one method. They run from the method outward:

Two decorators on one methodTypeScript
function note(label: string) {  return function (method: unknown, context: ClassMethodDecoratorContext) {    console.log(`${label} decorates ${String(context.name)}`);  };} console.log("before the class"); class Cart {  @note("first") @note("second") checkout() {}} console.log("after the class");new Cart().checkout();

Line 1's note makes a decorator that prints its label. Line 10 puts two on checkout. It prints before the class, then second decorates checkout, then first decorates checkout, then after the class. The decorators run between lines 7 and 13, while the class is defined. Line 14 calls checkout, which prints nothing: decorators don't run again when you call the method.

@note("first") @note("second") checkout(). The checkout method goes to second first, then the result goes to first, and the class keeps what first gives back.@note("first") @note("second") checkout()checkoutthe method1. secondafter second2. firstafter firstwhat the class keeps
With @note("first") @note("second"), the decorator closest to the method, second, gets checkout first. first gets whatever second gave back.

So with @bound @logged, logged wraps the method first and bound binds the wrapper. The cart panel at the end of this lesson does exactly that.

Field and accessor decorators

A field is a property with a starting value, written in the class body. A field decorator can't see the value yet, so its value is undefined. It can give back a function that changes the starting value:

A field decorator that trims the starting textTypeScript
function trimmed<This>(value: undefined, context: ClassFieldDecoratorContext<This, string>) {  return function (initial: string) {    return initial.trim();  };} class SearchBox {  @trimmed query = "  tea  ";} console.log(`[${new SearchBox().query}]`);

Line 1 types context as ClassFieldDecoratorContext<This, string>: a field that holds a string. Line 2 returns a function that gets the starting value, and line 3 trims it. Line 8 decorates query, so a new search box starts with "tea", and line 11 prints [tea].

A string decorator on a number field (an error on purpose)TypeScript
function trimmed<This>(value: undefined, context: ClassFieldDecoratorContext<This, string>) {  return function (initial: string) {    return initial.trim();  };} class SearchBox {  @trimmed limit = 10;}

Line 8 puts @trimmed on limit, a number. TypeScript marks it, on purpose: Unable to resolve signature of property decorator when called as an expression, because a ClassFieldDecoratorContext<SearchBox, number> isn't the string field trimmed asks for. You can't trim a number.

A field decorator only changes the starting value. To check every value someone sets later, use an auto-accessor: a field written with the accessor keyword (TypeScript 4.9 added it). It works like a field, but it's stored behind a hidden getter and setter, so a decorator can replace them:

An accessor decorator that checks every new valueTypeScript
function positive<This>(  target: ClassAccessorDecoratorTarget<This, number>,  context: ClassAccessorDecoratorContext<This, number>,): ClassAccessorDecoratorResult<This, number> {  return {    set(value) {      if (value <= 0) throw new RangeError(`${String(context.name)} must be above 0`);      target.set.call(this, value);    },  };} class CartLine {  @positive accessor quantity = 1;} const line = new CartLine();line.quantity = 3;console.log(line.quantity);try {  line.quantity = 0;} catch (error) {  console.log((error as Error).message);}console.log(line.quantity);

Line 2's target holds the hidden get and set. Line 4 says positive gives back new ones, and lines 6 to 9 replace only set: it throws a RangeError for 0 or less, and otherwise calls the real setter. Line 14 decorates quantity. Line 18 sets 3, and line 19 prints 3. Line 21 tries 0: line 23 prints quantity must be above 0, and line 25 still prints 3.

Decorator factories and class decorators

A decorator factory is a function that returns a decorator. You call it in the @ line, like @register("cart-badge"), to give the decorator settings. Here it's on a whole class:

A class decorator from a factoryTypeScript
const registry = new Map<string, unknown>(); function register(tag: string) {  return function (value: unknown, context: ClassDecoratorContext) {    registry.set(tag, value);    console.log(`registered <${tag}> as ${String(context.name)}`);  };} @register("cart-badge")class CartBadge {} console.log(registry.get("cart-badge") === CartBadge);

Line 3's register takes a tag name and returns the real decorator on line 4. A class decorator gets the class itself as value, and a ClassDecoratorContext. Line 10 calls register("cart-badge"), and the decorator it returns runs on CartBadge. Line 5 saves the class in a Map under its tag, and line 6 prints registered <cart-badge> as CartBadge. Line 13 prints true: the map holds the class. A web component library can register your custom elements this way, so you don't call customElements.define yourself.

Forget the brackets on a factory, and TypeScript notices:

A factory used without its brackets (an error on purpose)TypeScript
function logged(prefix: string) {  return function <This, Args extends unknown[], Return>(    method: (this: This, ...args: Args) => Return,    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,  ) {    return function (this: This, ...args: Args): Return {      console.log(`[${prefix}] ${String(context.name)}(${args.join(", ")})`);      return method.call(this, ...args);    };  };} class Cart {  items: string[] = [];   @logged  addItem(item: string) {    this.items.push(item);    return this.items.length;  }}

Lines 1 to 11 are a factory: logged(prefix) returns the decorator on line 2. Line 16 writes @logged with no brackets, so JavaScript would call logged itself as the decorator, with the method as the prefix. TypeScript marks line 16, on purpose: the next line of the message says Argument of type '(item: string) => number' is not assignable to parameter of type 'string'. Write @logged("Cart") instead.

The older experimentalDecorators

Long before JavaScript's decorator proposal took its current shape, TypeScript had its own, based on an older draft. You turn them on with "experimentalDecorators": true in tsconfig.json. Angular and NestJS were built on these, and their projects turn the flag on. The same @logged line then calls the decorator in a different way:

An experimental method decoratorTypeScript
function logged(target: object, key: string, descriptor: PropertyDescriptor) {  const method = descriptor.value;  descriptor.value = function (this: unknown, ...args: unknown[]) {    console.log(`${key}(${args.join(", ")})`);    return method.apply(this, args);  };} class Cart {  items: string[] = [];   @logged  addItem(item: string) {    this.items.push(item);    return this.items.length;  }} console.log(new Cart().addItem("Tea"));

The list under the code shows the option this example sets. Line 1 is the old signature, with three inputs: target, the class's prototype (the object its methods live on); key, the method's name; and a property descriptor, the object that holds the method in its value. Line 3 swaps the method for a wrapper by changing descriptor.value. It prints addItem(Tea), then 1.

Left, standard decorators with no flag: logged gets two inputs, value and context. Right, with experimentalDecorators: logged gets three inputs, target, key, and descriptor.standard: no flagloggedvaluecontext2 inputsTypeScript 5.0 and laterexperimentalDecoratorsloggedtargetkeydescriptor3 inputsAngular, NestJS
The same @logged line calls the decorator in two different ways. Standard decorators get (value, context). With experimentalDecorators, a method decorator gets (target, key, descriptor).

The two kinds don't mix. Try each decorator with the flag on and off:

Playground: one decorator, two compilers
The same @logged, compiled two waysTypeScript
function logged(value: unknown, context: ClassMethodDecoratorContext) {  console.log(`logged ${String(context.name)}`);} class Cart {  @logged  addItem(item: string) {    return 1;  }} console.log(new Cart().addItem("Tea"));
Compiled and runlogged addItem
What happens
experimentalDecoratorsoff
TypeScriptNo errors
Try it yourself

A standard decorator, with the flag off: the way TypeScript 5.0 and later compile decorators by default. logged gets the method and a context, and context.name is "addItem".

TypeScript checks each version for real, with the option the box sets, and the output is what tsc's compiled code prints. The buttons change lines 1 to 3. Reset goes back to a standard decorator with the flag off.

Each decorator only works with its own setting. With the wrong one, TypeScript marks line 6, and the compiled code passes the wrong inputs: the standard decorator prints logged undefined, and the experimental one prints logged [object Object].

The old kind can do two things the standard kind can't. First, it can decorate a constructor's parameters:

A parameter decorator, experimental onlyTypeScript
function inject(token: string) {  return function (target: object, key: string | undefined, index: number) {    console.log(`parameter ${index} of ${key ?? "the constructor"} needs ${token}`);  };} class CheckoutService {  constructor(@inject("api") api: string) {}}

Line 8 puts @inject("api") on the constructor's api parameter. NestJS and Angular use decorators like this to say what to pass into a class. When the class is defined, it prints parameter 0 of the constructor needs api. Without the flag, the same code fails:

The same parameter decorator, standard (an error on purpose)TypeScript
function inject(token: string) {  return function (target: object, key: string | undefined, index: number) {    console.log(`parameter ${index} of ${key ?? "the constructor"} needs ${token}`);  };} class CheckoutService {  constructor(@inject("api") api: string) {}}

Line 8 gets Decorators are not valid here: standard decorators can't go on parameters.

Second, emitDecoratorMetadata. With this option, tsc writes the types of a decorated class's constructor parameters into the compiled code, so a framework can read them while the page runs. It belongs to the old kind: tsc refuses it without experimentalDecorators, with Option 'emitDecoratorMetadata' cannot be specified without specifying option 'experimentalDecorators'.

A class that needs an ApiClientTypeScript
class ApiClient {} function injectable(target: unknown) {  console.log("injectable", (target as { name: string }).name);} @injectableclass CheckoutService {  constructor(private api: ApiClient) {}}

Line 9's constructor takes an ApiClient, and line 7 decorates the class. It prints injectable CheckoutService. Here is part of what tsc writes for lines 7 and 8:

What tsc writes, shortenedJavaScript
CheckoutService = __decorate([
    injectable,
    __metadata("design:paramtypes", [ApiClient])
], CheckoutService);

Line 3 is the metadata: "design:paramtypes", the constructor's parameter types, is the list [ApiClient]. The type became a value in the code. NestJS reads it, with a library called reflect-metadata, to know it should create an ApiClient and pass it in.

The table below sums up the differences. Read each row as a question, then each kind's answer.

Standard decorators and experimentalDecorators
QuestionStandard decoratorsexperimentalDecorators
How do you turn them on?Nothing to turn on, since TypeScript 5.0.Set "experimentalDecorators": true in tsconfig.json.
What does a method decorator get?Two inputs: (value, context).Three inputs: (target, key, descriptor).
Can you decorate a constructor parameter?No. TypeScript says Decorators are not valid here.Yes, like constructor(@inject("api") api: string).
Does emitDecoratorMetadata work?No. tsc refuses it without experimentalDecorators.Yes. tsc writes each constructor's parameter types into the code.
Who uses it?New code, and libraries written for the JavaScript proposal.Angular and NestJS projects, and older libraries.
Does it match JavaScript's decorators?Yes. It follows the proposal JavaScript is adding.No. It follows an older draft of that proposal.

In an Angular or NestJS project, keep the setting the framework chose. For your own decorators in other projects, use the standard kind.

A cart panel built from decorators

In a real project, decorators live in a file of their own, and classes import them. Here is a decorators.ts holding three from this lesson, written with export:

decorators.tsTypeScript
export function bound<This extends object, Args extends unknown[], Return>(  method: (this: This, ...args: Args) => Return,  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,) {  context.addInitializer(function () {    Reflect.set(this, context.name, method.bind(this));  });} export function logged(prefix: string) {  return function <This, Args extends unknown[], Return>(    method: (this: This, ...args: Args) => Return,    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,  ) {    return function (this: This, ...args: Args): Return {      console.log(`[${prefix}] ${String(context.name)}(${args.join(", ")})`);      return method.call(this, ...args);    };  };} export function positive<This>(  target: ClassAccessorDecoratorTarget<This, number>,  context: ClassAccessorDecoratorContext<This, number>,): ClassAccessorDecoratorResult<This, number> {  return {    set(value) {      if (value <= 0) throw new RangeError(`${String(context.name)} must be above 0`);      target.set.call(this, value);    },  };}

Lines 1 to 8 are bound, lines 10 to 20 the logged factory, and lines 22 to 32 positive, the same code as earlier sections. A cart panel uses all three:

cart-panel.tsTypeScript
import { bound, logged, positive } from "./decorators"; class CartPanel {  @positive accessor quantity = 1;   @bound  @logged("CartPanel")  addOne() {    this.quantity += 1;    return this.quantity;  }} const panel = new CartPanel();const onPlusClick = panel.addOne;console.log(onPlusClick());console.log(onPlusClick());try {  panel.quantity = -1;} catch (error) {  console.log((error as Error).message);}

Line 1 imports the decorators (Modules in TypeScript covers imports). Line 4 makes quantity an auto-accessor that refuses 0 or less. Lines 6 and 7 stack two decorators on addOne: logged wraps it, then bound binds the wrapper. Line 15 copies the method, as a plus button's click listener would. Lines 16 and 17 print [CartPanel] addOne() and 2, then [CartPanel] addOne() and 3. Line 19 tries -1, and line 21 prints quantity must be above 0.

Which kind of decorator is it?
  • @note class CartBadge {}
  • class Cart { @note checkout() {} }
  • class Cart { @note total = 0; }
  • class Cart { @note accessor count = 0; }
  • class Cart { @note static create() { return new Cart(); } }
  • class Cart { @note static instances = 0; }
  • class Cart { @note #save() {} }
  • class SearchBox { @note accessor query = ""; }
Try it yourself
0 of 8 correct

Each line puts @note on part of a class. Sort it by the context.kind its decorator gets.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A decorator runs every time I call the method." No. It runs once, while the class is defined. Whatever it gives back, like a logging wrapper, is what runs on each call.
  • "Decorators are a TypeScript-only feature." No. Standard decorators are a JavaScript feature that TypeScript compiles for you. The older experimentalDecorators are TypeScript's own.
  • "Any decorator works anywhere." No. Each place passes its own context, and TypeScript checks that the decorator accepts it. A method decorator on a field gets an error.
  • "@logged and @logged("Cart") are the same." No. With brackets you call a factory, which returns the decorator. Without them, the function itself is the decorator.
  • "Angular's decorators and TypeScript 5's are the same thing." No. Angular uses experimentalDecorators, which passes different inputs, allows parameter decorators, and works with emitDecoratorMetadata.
  • "A field decorator can check every value I set." No. It only sees the starting value. Use an accessor field, whose decorator can replace set.

Practice exercises

These exercises use decorators, so their code is type-checked on the page but has no Edit & run. Write your answer in your head or on paper, check it against the errors under the code, and use the hints. The lesson's tests compile each solution and check what it prints.

Exercise 1 · Warm-upFind the decorator in the wrong place

A field decorator, used twice. Which line will TypeScript mark?

Starter codeTypeScript
function trimmed<This>(value: undefined, context: ClassFieldDecoratorContext<This, string>) {
  return (initial: string) => initial.trim();
}

class SearchBox {
  @trimmed query = "  tea  ";
  @trimmed search() { return this.query; }
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict when a factory runs

    Each factory call prints a line, and so does each decorator. What does the code print, in order?

    Starter codeTypeScript
    function note(label: string) {  console.log(`make ${label}`);  return function (method: unknown, context: ClassMethodDecoratorContext) {    console.log(`apply ${label}`);  };} class Cart {  @note("A") @note("B") checkout() {}}

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeTurn logged into a factory

      Change logged so you can write @logged("Cart") and each line starts with [Cart]. What does your version print?

      Starter codeTypeScript
      function logged<This, Args extends unknown[], Return>(  method: (this: This, ...args: Args) => Return,  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,) {  return function (this: This, ...args: Args): Return {    console.log(`${String(context.name)}(${args.join(", ")})`);    return method.call(this, ...args);  };} class Cart {  items: string[] = [];   @logged  addItem(item: string) {    this.items.push(item);    return this.items.length;  }} new Cart().addItem("Tea");

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep this in a search handler

        The starter throws when onInput runs, as an input listener would call it. Fix it with a decorator. What does the fixed code print?

        Starter codeTypeScript
        function bound<This extends object, Args extends unknown[], Return>(  method: (this: This, ...args: Args) => Return,  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,) {  context.addInitializer(function () {    Reflect.set(this, context.name, method.bind(this));  });} class SearchBox {  query = "tea";   handleInput() {    console.log(`Searching for ${this.query}`);  }} const box = new SearchBox();const onInput = box.handleInput;onInput();

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWrite a maxLength check

          A review form's comment must be at most 20 characters. Write a @maxLength(20) decorator for the accessor field. What does your version print first?

          Starter codeTypeScript
          class ReviewForm {  accessor comment = "";} const form = new ReviewForm();form.comment = "Lovely tea";try {  form.comment = "This tea is lovely, and the cake is even better";} catch (error) {  console.log((error as Error).message);}console.log(form.comment);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeMove an experimental decorator to the standard kind

            This decorator needs experimentalDecorators. Rewrite it as a standard decorator that prints the same lines. How many inputs does the standard decorator take?

            Starter codeTypeScript
            function logged(target: object, key: string, descriptor: PropertyDescriptor) {  const method = descriptor.value;  descriptor.value = function (this: unknown, ...args: unknown[]) {    console.log(`${key}(${args.join(", ")})`);    return method.apply(this, args);  };} class Cart {  items: string[] = [];   @logged  addItem(item: string) {    this.items.push(item);    return this.items.length;  }} console.log(new Cart().addItem("Tea"));

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask two things: when the decorator runs, and what it gets. The quiz shows its code without the checker, so the error list doesn't give the answers away.

              The decorators quiz · 7 questionsScore: first tries count
              1. Question 1 of 7How many times does this code print decorating checkout?

                Read the code, then predictTypeScript
                function counted(method: unknown, context: ClassMethodDecoratorContext) {
                  console.log(`decorating ${String(context.name)}`);
                }
                
                class Cart {
                  @counted checkout() {}
                }
                
                const cart = new Cart();
                cart.checkout();
                cart.checkout();

                Choose an answer to see the explanation.

              2. Question 2 of 7Which line of this class does TypeScript flag?

                Read the code, then predictTypeScript
                function logged(value: unknown, context: ClassMethodDecoratorContext) {}
                
                class Cart {
                  @logged total = 0;
                  @logged checkout() {}
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7A decorator sits on accessor count = 0. What is its context.kind?

                Choose an answer to see the explanation.

              4. Question 4 of 7What do these two stacked decorators print?

                Read the code, then predictTypeScript
                function tag(label: string) {
                  return function (method: unknown, context: ClassMethodDecoratorContext) {
                    console.log(label);
                  };
                }
                
                class Cart {
                  @tag("outer") @tag("inner") checkout() {}
                }

                Choose an answer to see the explanation.

              5. Question 5 of 7Which of these needs experimentalDecorators?

                Choose an answer to see the explanation.

              6. Question 6 of 7logged is a factory: logged(prefix) returns the decorator. What happens if you write @logged with no brackets on a method?

                Choose an answer to see the explanation.

              7. Question 7 of 7After const onClick = button.handleClick, why does a @bound handler still know its button?

                Choose an answer to see the explanation.

              Key takeaways

              • A decorator is a function you put on a class or a member with @. It runs once, while the class is defined.
              • A standard decorator gets (value, context). Each place has its own context type, like ClassMethodDecoratorContext.
              • Typed with generics, a decorator keeps the method's types, and TypeScript marks it in the wrong place or on the wrong kind of method.
              • addInitializer runs setup for each new object, which is how @bound keeps this in event handlers.
              • Field decorators change the starting value; accessor decorators can check every value set.
              • A factory, like @register("cart-badge"), is called to make the decorator.
              • experimentalDecorators, used by Angular and NestJS, passes different inputs, allows parameter decorators, and is the only kind that works with emitDecoratorMetadata.

              Remember the one-liner.
              A decorator runs once while the class is defined, gets a member and its context, and gives back what the class keeps.

              Coming next: Migrating JavaScript to TypeScript. It moves a real project from JavaScript to TypeScript one file at a time, without stopping the team.

              CompleteFrontend Clear concepts. Working examples.