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.
- 01Write standard decoratorsWrite method, field, accessor, and class decorators, and decorator factories, for logging, timing, binding, and simple checks.
- 02Type 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. - 03Tell the two kinds apartExplain how
experimentalDecorators, used by Angular and NestJS, differs, and whyemitDecoratorMetadataonly 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:
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, theaddItemfunction you wrote on lines 13 to 16.context, an object that describes it. Line 2 reads itsname,"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.
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.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.
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:
loggedruns once, when the class is defined - In real life: Visitors still reach the same office
- In TypeScript: The original method still does the work
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:
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 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.
| You put it on | Its context type | What value is, and what it can give back |
|---|---|---|
A class: @register("cart-badge") class CartBadge | ClassDecoratorContext | The class itself. It can give back a new class to use instead. |
A method: @logged addItem() | ClassMethodDecoratorContext | The method. It can give back a function to use instead. |
A getter or a setter: @logged get size() | ClassGetterDecoratorContext or ClassSetterDecoratorContext | The getter or setter. It can give back one to use instead. |
A field: @trimmed query = "" | ClassFieldDecoratorContext | undefined, because the field has no value yet. It can give back a function that changes the starting value. |
An auto-accessor: @positive accessor quantity = 1 | ClassAccessorDecoratorContext | An 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:
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; }}logged method checkoutline 7No errorscheckout 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.
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:
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. Foradd, it'sCart.Args: the list of parameter types, here[price: number, quantity: number].Return: what the method gives back, herenumber.
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:
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 saysArgument 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:
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:
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();Clicked 1 timesClicked 2 timesLine 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.
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:
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"), 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:
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].
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:
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:
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:
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:
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.
@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:
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"));logged addItemoffNo errorsA 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".
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:
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:
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'.
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:
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.
| Question | Standard decorators | experimentalDecorators |
|---|---|---|
| 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:
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:
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.
@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 = ""; }
Each line puts @note on part of a class. Sort it by the context.kind its decorator gets.
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
experimentalDecoratorsare 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 withemitDecoratorMetadata. - "A field decorator can check every value I set." No. It only sees the starting value. Use an
accessorfield, whose decorator can replaceset.
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.
A field decorator, used twice. Which line will TypeScript mark?
function trimmed<This>(value: undefined, context: ClassFieldDecoratorContext<This, string>) {
return (initial: string) => initial.trim();
}
class SearchBox {
@trimmed query = " tea ";
@trimmed search() { return this.query; }
}function trimmed<This>(value: undefined, context: ClassFieldDecoratorContext<This, string>) { return (initial: string) => initial.trim();} class SearchBox { @trimmed query = " tea "; search() { return this.query; } // a method can't take a field decorator}Line 6's query is a string field, so @trimmed fits. Line 7's search is a method, which gets a ClassMethodDecoratorContext, so TypeScript marks line 7. Removing the decorator from the method fixes it.
Each factory call prints a line, and so does each decorator. What does the code print, in order?
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() {}}function note(label: string) { console.log(`make ${label}`); // runs as each @note(...) is read, top to bottom return function (method: unknown, context: ClassMethodDecoratorContext) { console.log(`apply ${label}`); // runs closest to the method first };} class Cart { @note("A") @note("B") checkout() {}}The factory calls run first, in reading order: make A, make B. Then the decorators are applied from the method outward: apply B, apply A.
Change logged so you can write @logged("Cart") and each line starts with [Cart]. What does your version print?
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");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("Cart") addItem(item: string) { this.items.push(item); return this.items.length; }} new Cart().addItem("Tea");logged("Cart") returns the decorator, which remembers prefix. The wrapper prints [Cart] addItem(Tea) before calling the original.
The starter throws when onInput runs, as an input listener would call it. Fix it with a decorator. What does the fixed code print?
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();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"; @bound handleInput() { console.log(`Searching for ${this.query}`); }} const box = new SearchBox();const onInput = box.handleInput;onInput();The starter throws TypeError: Cannot read properties of undefined (reading 'query'). With @bound, each new search box stores a copy of handleInput bound to itself, so onInput() prints Searching for tea.
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?
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);function maxLength(limit: number) { return function <This>( target: ClassAccessorDecoratorTarget<This, string>, context: ClassAccessorDecoratorContext<This, string>, ): ClassAccessorDecoratorResult<This, string> { return { set(value) { if (value.length > limit) throw new RangeError(`${String(context.name)} is longer than ${limit}`); target.set.call(this, value); }, }; };} class ReviewForm { @maxLength(20) 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);@maxLength(20) replaces the comment's setter. The first comment is 10 characters, so it's stored. The second is longer than 20, so the setter throws, the catch prints comment is longer than 20, and the comment stays Lovely tea.
This decorator needs experimentalDecorators. Rewrite it as a standard decorator that prints the same lines. How many inputs does the standard decorator take?
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"));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; }} console.log(new Cart().addItem("Tea"));The standard decorator takes two inputs, the method and its context, and returns the wrapper instead of changing a descriptor. With no option set, it prints the same addItem(Tea) and 1.
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.
Question 1 of 7How many times does this code print
decorating checkout?Read the code, then predictTypeScriptfunction 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.
Question 2 of 7Which line of this class does TypeScript flag?
Read the code, then predictTypeScriptfunction logged(value: unknown, context: ClassMethodDecoratorContext) {} class Cart { @logged total = 0; @logged checkout() {} }Choose an answer to see the explanation.
Question 3 of 7A decorator sits on
accessor count = 0. What is itscontext.kind?Choose an answer to see the explanation.
Question 4 of 7What do these two stacked decorators print?
Read the code, then predictTypeScriptfunction 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.
Question 5 of 7Which of these needs
experimentalDecorators?Choose an answer to see the explanation.
Question 6 of 7
loggedis a factory:logged(prefix)returns the decorator. What happens if you write@loggedwith no brackets on a method?Choose an answer to see the explanation.
Question 7 of 7After
const onClick = button.handleClick, why does a@boundhandler 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, likeClassMethodDecoratorContext. - 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.
addInitializerruns setup for each new object, which is how@boundkeepsthisin event handlers.- Field decorators change the starting value;
accessordecorators 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 withemitDecoratorMetadata.
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.