Decorators
Wrap classes, methods, fields, and accessor properties with reusable behavior while staying honest about the proposal, metadata, and today's tooling.
- 01Wrap behavior safelyModel method decorators that log calls or bind
thiswithout changing every method body. - 02Initialize data deliberatelyUse field and auto-accessor decorators to transform initial values and guard later writes.
- 03Choose tooling with careSeparate the current TC39 proposal from older TypeScript decorator behavior and native support.
Decorators are class finishing tools
A decorator is a function that receives part of a class while the class is being defined. It can look at the part, wrap it, replace it, register setup work, or attach metadata for other code to read later.
The current proposal-style shape is (value, context). value is the method, field, auto-accessor, or class piece being decorated. context is a label and toolbox: it tells the decorator the kind, name, whether the member is static or private, and exposes tools such as addInitializer and metadata.
Decorators let you write reusable class add-ons once, then apply them to methods, fields, auto-accessors, or classes instead of copying the same logging, binding, validation, or metadata code.
Imagine wrapping a present before giving it away. The wrapping can add a ribbon, a label, or an instruction. A decorator adds behavior before you create instances of the class.
- In real life: A present
- In JavaScript: A class element while the class is defined
- In real life: Wrapping and a ribbon
- In JavaScript: A decorator wraps, replaces, initializes, or annotates
- In real life: The finished gift
- In JavaScript: The class is ready before you create instances
Where the analogy stops: Wrapping is only a picture. Decorators run during class definition, while instance initializers run later.
This lesson keeps the runnable demos in plain JavaScript. The code blocks show decorator syntax, and the tests compile that syntax with TypeScript, but the live labs run a desugared model made from ordinary function calls. That keeps the page honest in browsers that cannot parse decorator syntax yet.
Method decorators wrap behavior
INTERACTIVEA method decorator receives the original function. If it returns a new function, that new function becomes the method. That is perfect for logging, timing, memoizing, retrying, or checking permissions.
The lab below shows two common decorators. logged wraps a method so it prints before and after the real call. bound registers an initializer so each instance binds its method once, which matters when a method is detached from its object.
Before wrapping, you need to know what is in the lunch box. Is it a method? What is its name? Is it static or private? The context object answers those questions and supplies safe tools.
- In real life: A label says whose lunch it is
- In JavaScript:
context.kindandcontext.nameidentify what is decorated - In real life: The label includes useful notes
- In JavaScript:
addInitializer,access, andmetadataare decorator tools - In real life: Read the label before wrapping
- In JavaScript: The decorator reads context before returning a wrapper
Where the analogy stops: A real label cannot do anything. A decorator context also has callable tools.
function logged(value, context) { return function (...args) { console.log("call " + String(context.name)); const result = value.apply(this, args); console.log("return " + result); return result; };} function bound(value, context) { context.addInitializer(function () { this[context.name] = this[context.name].bind(this); });} class Counter { count = 0; @logged @bound increment(step = 1) { this.count += step; return this.count; }} const counter = new Counter();const click = counter.increment;console.log(click(2));function logged(value, context) { return function (...args) { console.log("call " + String(context.name)); const result = value.apply(this, args); console.log("return " + result); return result; };} function bound(value, context) { context.addInitializer(function () { this[context.name] = this[context.name].bind(this); });} const context = { kind: "method", name: "increment", static: false, private: false, access: {}, metadata: {}, addInitializer(initializer) { initializers.push(initializer); }};const initializers = []; let increment = function (step = 1) { this.count += step; return this.count;};increment = bound(increment, context) ?? increment;increment = logged(increment, context) ?? increment; const counter = { count: 0, increment };for (const initializer of initializers) initializer.call(counter);const click = counter.increment;console.log(click(2));call increment(2)return 2console 2Final count: 2
The wrapper logged the call, ran the original method, then logged the result. Binding keeps the detached method attached to its instance.
Try turning bound off while the detached call is on. The method is called without its object, so this is lost. Turn bound back on and the initializer fixes the instance before the detached call happens.
What the engine does while defining the class
STEP THROUGHDecorators are not called each time you call a method. They run while the class is being defined. The replacement method they return, or the initializer they register, affects what happens later.
This is a replay of a hand-written model of class definition time: decorator expressions are evaluated, decorators are called, replacements are installed, and instance initializers run later.
script
return function (...args) { console.log("call " + String(context.name)); const result = value.apply(this, args); console.log("return " + result); return result; };} function bound(value, context) { context.addInitializer(function () { this[context.name] = this[context.name].bind(this); });} class Counter { count = 0; @logged @bound increment(step = 1) { this.count += step; return this.count; }} const counter = new Counter();const click = counter.increment;console.log(click(2));In the method version, the decorator returns a wrapper function. In the field version, the decorator returns an initializer transform. The transform receives the field’s starting value every time a new instance is created.
Decorator expressions are collected from top to bottom, but calls are applied from the closest decorator outward. If two wrappers both return functions, the top wrapper usually sees the function returned by the lower wrapper.
Fields initialize once; auto-accessors can guard future writes
INTERACTIVEA field decorator is great when you only need to transform the initial value. A number can be clamped as an instance is created, or a missing string can be replaced with a default.
An auto-accessor creates a hidden storage slot with a public getter and setter door. A decorator can wrap the door: transform the starting value with init, change reads with get, and guard writes with set.
A plain field puts a value straight on a desk. An auto-accessor is more like a jar with a lid: values go through the lid, and a decorator can clean or reject them.
- In real life: A jar holds the item
- In JavaScript: The auto-accessor has hidden storage
- In real life: A lid controls access
- In JavaScript:
getandsetcontrol reads and writes - In real life: A check before closing the lid
- In JavaScript:
initorsetcan trim, clamp, or validate
Where the analogy stops: The storage slot comes from the language, not a jar you can open directly. Decorators get only the allowed hooks.
function clamp(value, context) { return function (initialValue) { return Math.min(100, Math.max(0, initialValue)); };} function trimBox(value, context) { return { init(initialValue) { return initialValue.trim(); }, get() { return value.get.call(this); }, set(nextValue) { value.set.call(this, nextValue.trim()); } };} class Profile { @clamp percent = 135; @trimBox accessor nickname = " Ada ";} const profile = new Profile();console.log(profile.percent);console.log(profile.nickname);profile.nickname = " Grace ";console.log(profile.nickname);const metadata = {};const percentContext = { kind: "field", name: "percent", static: false, private: false, access: {}, metadata, addInitializer() {}};const nicknameContext = { kind: "accessor", name: "nickname", static: false, private: false, access: {}, metadata, addInitializer() {}}; const clampInit = clamp(undefined, percentContext);const wrapped = trimBox({ get() { return this._nickname; }, set(value) { this._nickname = value; }}, nicknameContext); const profile = {};profile.percent = clampInit(135);profile._nickname = wrapped.init(" Ada ");Object.defineProperty(profile, "nickname", { get() { return wrapped.get.call(profile); }, set(value) { wrapped.set.call(profile, value); }});console.log(profile.percent);console.log(profile.nickname);profile.nickname = " Grace ";console.log(profile.nickname);100"Ada""Grace"The field initializer clamps the starting percent once. The auto-accessor wrapper trims the initial nickname and the later assignment.
Decorator metadata is information for other code
INTERACTIVESometimes a decorator does not need to wrap anything. It just records facts: “this method saves data,” “this field is required,” or “this class handles this route.” That is metadata.
The metadata proposal gives every decorator on the same class a shared context.metadata object. After the class is defined, code can read that object from the class through Symbol.metadata. Because metadata is a separate proposal, compiled examples may need a small Symbol.metadata fallback.
if (!Symbol.metadata) Symbol.metadata = Symbol("Symbol.metadata"); function label(text) { return function (value, context) { context.metadata[context.name] = text; };} class Task { @label("Saves draft data") save() {}} console.log(Task[Symbol.metadata].save);const metadataKey = Symbol.metadata ?? Symbol("Symbol.metadata");const metadata = {};const context = { kind: "method", name: "save", static: false, private: false, access: {}, metadata, addInitializer() {}}; label("Saves draft data")(function save() {}, context);class Task {}Task[metadataKey] = metadata; console.log(Task[metadataKey].save);Saves draft dataThe decorator writes to the shared metadata object. Later code can read that object from the class using the metadata symbol.
Symbol.metadata fallback.Proposal status, TypeScript, Babel, and native support
DETECTAt the time of writing, treat decorators as syntax you compile, not syntax every JavaScript engine will parse directly. I checked the TC39 proposal materials, the decorator-metadata README, and Node 22. Node 22 rejected a normal script containing decorator syntax.
TypeScript can compile the proposal-style syntax used in the examples in this lesson. That is different from TypeScript’s older experimentalDecorators mode, which used the legacy (target, key, descriptor) shape. Babel can also compile decorators, but you must choose a plugin/version that matches your codebase.
const code = "function dec(value, context) {}\n@dec class C {}";new Function(code); // parse test onlychecking...Waiting for the iframe to report a parse result.
| Environment | What to expect today | Practical note |
|---|---|---|
| Native JavaScript engines | Do not assume on-by-default support | Feature-detect or compile; Node 22 rejects the syntax in a normal script. |
| TypeScript | Compiles the current proposal-style syntax | This is different from the older experimentalDecorators mode used by many libraries. |
| Babel | Can compile proposal versions through plugins | Choose the version that matches your codebase and framework. |
| Metadata | A separate proposal | Symbol.metadata may need a polyfill before compiled code can store metadata. |
Choose the decorator kind by the job
SORTERAsk what you need to change. Do you need the whole class, a callable method, a starting field value, or a property door that sees future reads and writes?
- Register the whole class with a framework container
- Log every call to
save()and then run the original method - Clamp an instance field's starting score from 135 down to 100
- Trim a public property when it is first created and whenever it is set
- Attach route metadata to a controller class
- Memoize
total()so repeated method calls reuse a cached result - Replace an empty field's initial value with
"draft" - Reject future writes to a public name when the value is blank
Sort each requirement into the class element a decorator should target.
Where you will use decorators
Decorators shine when a cross-cutting behavior appears in many places: logging every command method, binding event handlers, validating values, annotating routes, or describing fields for serialization. They keep the core method small while the reusable wrapper handles the repeated concern.
- UI classes can bind methods once so callbacks do not lose
this. - Models can clamp or normalize incoming data before storing it.
- Frameworks can collect route or validation metadata at class definition time.
- Libraries can memoize expensive methods without rewriting the method body.
These ideas connect to earlier lessons on class basics, private members, getters and setters, closures, and higher-order functions.
Common misconceptions
| Feature | What the decorator receives | What it may return | When the effect happens |
|---|---|---|---|
| Method | The function plus context | A replacement function | Class definition time; the wrapper runs on each call |
| Field | undefined plus context | An initializer transform | When each instance initializes that field |
| Auto-accessor | An object with get and set plus context | get, set, and/or init | Reads, writes, and initial value setup |
| Metadata | The same context object includes metadata | Usually nothing | Written during decoration, read later from the class |
- “Decorators are just comments.” They are functions that can change behavior.
- “Every engine runs them today.” Compile or feature-detect; do not assume native parsing.
- “Old TypeScript decorators are the same thing.” Legacy decorators use a different call shape.
- “A field decorator sees the field’s value immediately.” It returns an initializer that sees each instance’s value later.
- “Binding can happen before instances exist.” Binding needs
addInitializerbecause each instance has its own method value. - “Metadata is automatic reflection.” Decorators must choose what to write, and metadata support may need a fallback.
Practice exercises
5 EXERCISESRead the desugared method decorator. What are the two console lines?
function logged(value, context) {
return function (...args) {
console.log("call " + String(context.name));
return value.apply(this, args);
};
}
const context = { kind: "method", name: "total", static: false, private: false, access: {}, metadata: {}, addInitializer() {} };
const original = function (a, b) { return a + b; };
const total = logged(original, context);
console.log(total(2, 3));The wrapper logs call total, then the original function returns 5, so the final console line is 5.
Predict the two numbers printed by this field-initializer model.
function clamp(initialValue) {
return Math.min(10, Math.max(0, initialValue));
}
console.log(clamp(18));
console.log(clamp(-3));18 is above the maximum, so it becomes 10. -3 is below the minimum, so it becomes 0.
A learner wrote a bound decorator but detached calls still fail. Explain the missing step and write the core line of the fix.
context.addInitializer(function () {
this[context.name] = this[context.name].bind(this);
});The decorator should register work with addInitializer. That work runs for each instance and replaces the method with a bound function.
What string does this metadata lookup print?
const metadata = {};
metadata.save = "writes data";
console.log(metadata.save);The metadata object stores metadata.save = "writes data", so reading it prints writes data.
You need to trim a property when the instance is created and again every time someone assigns to it. Which decorator kind fits?
Use an auto-accessor decorator. It can normalize the initial value and every future assignment through set.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which signature matches the current proposal-style decorator shape?
Choose an answer to see the explanation.
Question 2 of 8What does the desugared logged method print first?
Read the code, then predictfunction logged(value, context) { return function (...args) { console.log("call " + String(context.name)); return value.apply(this, args); }; } const context = { kind: "method", name: "total", static: false, private: false, access: {}, metadata: {}, addInitializer() {} }; const original = function (a, b) { return a + b; }; const total = logged(original, context); console.log(total(2, 3));Choose an answer to see the explanation.
Question 3 of 8What can a field decorator return?
Choose an answer to see the explanation.
Question 4 of 8What does the clamp exercise print for high and low values?
Read the code, then predictfunction clamp(initialValue) { return Math.min(10, Math.max(0, initialValue)); } console.log(clamp(18)); console.log(clamp(-3));Choose an answer to see the explanation.
Question 5 of 8Why does a binding decorator use
addInitializer?Choose an answer to see the explanation.
Question 6 of 8What is special about decorator metadata?
Choose an answer to see the explanation.
Question 7 of 8Which statement is safest about native support?
Choose an answer to see the explanation.
Question 8 of 8Which need belongs to an auto-accessor decorator?
Choose an answer to see the explanation.
Key takeaways
- Decorators are functions called while a class is being defined.
- Proposal-style decorators receive
(value, context), not the older legacy TypeScript descriptor shape. - Methods may return replacement functions; fields may return initializer transforms.
- Auto-accessors let decorators wrap initial value setup, reads, and writes.
- Metadata is separate and may require
Symbol.metadatasupport. - Compile or feature-detect before shipping decorator syntax.
A decorator is a class-definition-time function that can wrap, initialize, or annotate a class or class element.
Up next: eval & new Function.