Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
Decorators for logging let you add tracing, timing, or access checks to a class or method without touching its original code. A decorator is a function that wraps a class, method, or field to add that behavior from the outside. Logging is one of the most common uses, since instead of writing logging calls inside every method you want to trace, you write one reusable decorator and apply it wherever you need it.
This solves a real architecture problem. Logging and timing are cross-cutting concerns: they touch many unrelated classes but are not part of what those classes actually do. Mixing that code into your business logic makes both harder to read.
Decorators are a Stage 3 TC39 proposal. No browser and no version of Node.js parses the decorator syntax natively yet. Every example below needs Babel or TypeScript 5 or later to compile into plain JavaScript before it runs.
Here is the smallest working version of this idea, adapted from the logging example in the official Stage 3 proposal:
function logged(value, { kind, name }) {
if (kind === "method") {
return function (...args) {
console.log(`start ${name}`);
const result = value.call(this, ...args);
console.log(`end ${name}`);
return result;
};
}
}
class Orders {
@logged
place(id) {
return `order ${id} placed`;
}
}
new Orders().place(42);Calling place with 42 prints two lines to the console: a start line right before the original method runs, and an end line right after it returns. The place method itself never mentions logging. The decorator line above it adds that behavior from the outside.
How a Decorator Wraps a Method
A decorator function always receives two arguments: the thing being decorated, and a context object describing it. For a method, the first argument is the method itself, and the object holds metadata about where it lives on the class.
function logged(value, context) {
console.log(context.kind, context.name);
return value;
}Applying this to a method named place prints "method place" once, when the class body is evaluated, not when place is called. That timing matters. The decorator function itself runs once per class definition, while the value it returns is what actually runs on every call afterward.
The context object carries what the decorator needs to know about the target:
| Property | Meaning |
|---|---|
| kind | What is being decorated: method, class, field, getter, setter, or accessor |
| name | The method or field name as a string |
| static | True if the member is declared with the static keyword |
| private | True if the member uses a private hash-prefixed name |
A logging decorator checks that kind is "method" and returns a new function that wraps the original one. The wrap flow looks like this:
The caller never knows a wrapper exists. It calls place and gets back the exact result it would get without the decorator. The two log lines are the only visible difference, and they happen on the way in and the way back out.
Setting Up Decorators in a JavaScript Project
Since no runtime understands this syntax yet, install Babel's decorator plugin as a dev dependency before writing any decorator code:
npm install --save-dev @babel/core @babel/cli @babel/plugin-proposal-decoratorsEnable it in babel.config.json, pointing at the version of the proposal that reached Stage 3 consensus. This tells Babel which decorator syntax and semantics to compile against:
{
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "2023-11" }]
]
}Run your source files through Babel before Node.js sees them, the same way you would for JSX or any other syntax the runtime does not understand on its own. If your project already uses TypeScript 5 or later, you get this behavior with no extra config, since modern decorators are enabled by default and do not need the older experimentalDecorators flag.
Building a Reusable Logging Decorator
The version above hardcodes a plain console line. A decorator meant for a real codebase should also record the arguments and how long the call took:
function logged(value, { kind, name }) {
if (kind !== "method") return value;
return function (...args) {
const start = performance.now();
console.log(`${name}(${args.join(", ")})`);
const result = value.call(this, ...args);
const ms = (performance.now() - start).toFixed(2);
console.log(`${name} finished in ${ms}ms`);
return result;
};
}Apply that same decorator to every method that needs tracing. It does not matter how many methods use it, since the timing and formatting logic lives in one place:
class InventoryService {
@logged
checkStock(sku) {
return sku === "SKU-1" ? 12 : 0;
}
@logged
reserve(sku, quantity) {
return `reserved ${quantity} of ${sku}`;
}
}
const service = new InventoryService();
service.checkStock("SKU-1");
service.reserve("SKU-1", 3);Calling checkStock and then reserve produces four lines of output: one line before each method runs showing its name and arguments, and one line after it finishes showing the elapsed time. Neither method contains a single logging statement of its own. If you later swap the console call for a real logging library inside the decorator, every decorated method across the codebase picks up the change automatically.
Logging When a Class Is Created
A method decorator wraps one function. A class decorator wraps the whole constructor instead, which is useful for logging when an instance is created rather than when a method runs. This is the shape to reach for when you want a line in the logs every time a particular service or worker is set up, not every time one of its methods fires.
function loggedClass(value, { name }) {
return class extends value {
constructor(...args) {
super(...args);
console.log(`created ${name}`);
}
};
}
@loggedClass
class PaymentProcessor {
constructor(amount) {
this.amount = amount;
}
}
new PaymentProcessor(50);Creating a new PaymentProcessor prints "created PaymentProcessor" to the console. The decorator returns a brand new class that extends the original one, calls super(...args) first so the original constructor still runs, then adds the logging line after it.
This pattern is useful for tracing which services get instantiated in a large application, similar to how a pub-sub event bus traces which components fire events.
Stacking Decorators and Execution Order
Real services often need more than one cross-cutting behavior, such as counting calls alongside logging them. Stacking two decorators on one method evaluates them top to bottom, but the wrapping applies bottom to top.
function counted(value, { name }) {
let calls = 0;
return function (...args) {
calls += 1;
console.log(`${name} call number ${calls}`);
return value.call(this, ...args);
};
}
class ReportService {
@logged
@counted
generate(id) {
return `report ${id}`;
}
}Here counted wraps generate first, and logged wraps the result of that. At call time, execution goes the other direction: logged runs first because it is the outermost wrapper, then it calls into counted, which finally calls the original generate.
The decorator closest to the method runs closest to the method's actual body, and the decorator furthest away runs first and last. Keep the order intentional: put the decorator that should see every call, such as logging, on the outside, and put narrower checks closer to the method itself.
Logging Async Methods
Most real logging targets asynchronous work like API calls, so the decorator needs to wait for the original method to settle before logging that it finished. A synchronous wrapper cannot do this correctly, since it would log the end line before the promise the method returned actually resolves.
function loggedAsync(value, { name }) {
return async function (...args) {
console.log(`start ${name}`);
try {
const result = await value.call(this, ...args);
console.log(`end ${name}`);
return result;
} catch (err) {
console.log(`failed ${name}: ${err.message}`);
throw err;
}
};
}Wrapping a method with loggedAsync instead of logged gives an async method the same before-and-after logging as a sync one, but through an await inside the wrapper. The try and catch block matters here. Without it, a rejected promise would skip the closing log line and the failure would look like it came from nowhere, so the wrapper logs the failure and rethrows the same error for whatever called the method.
Common Mistakes
These are the mistakes that show up most often once decorators for logging move from a small demo into a real codebase. Forgetting to return the wrapped function is the most common one. If the decorator does not return anything for the method case, the method silently becomes undefined and every call throws.
Losing the receiver inside the wrapper causes a subtler bug. Calling the original function directly instead of through .call(this, ...args) runs it with the wrong receiver, which breaks any method that reads its own instance state.
Assuming the decorator runs on every call is another trap. The decorator function itself runs once, when the class is defined. Any logging placed directly inside the decorator body, outside the returned wrapper, only fires once, not on every method call.
Relying on native support today will break your build. Writing decorator syntax in a plain .js file with no Babel or TypeScript step produces a syntax error in every current browser and in Node.js, so confirm your pipeline compiles decorators before shipping code that uses them.
When to Use a Logging Decorator
Reaching for decorators for logging makes the most sense once the same tracing code would otherwise be copied into several classes. A logging decorator is a good fit when several classes in a service layer or API client need the same trace logging, when you want to add or remove that logging without editing the methods themselves, and when the project already has a build step such as Babel or TypeScript, so the extra syntax has no added cost.
Skip it when the project has no build step and adding one just for logging is not worth it. A plain higher-order function that wraps a function reference does the same job in plain JavaScript today with no compiler required. It is also not worth it for one or two calls, where a direct log line next to the call site reads more clearly, or on an extremely hot path where even a small wrapper adds measurable overhead.
Rune AI
Key Insights
- Decorators for logging use a function that receives a value and a context object, then wrap a method to add behavior.
- Stage 3 decorators are not supported natively by any browser or Node.js yet, so you need Babel or TypeScript 5+ to run them.
- A decorator runs once, when the class is defined, and produces a wrapped function that runs on every call.
- A logging decorator must call the original method with the right receiver and always return its result.
- Stacked decorators evaluate top to bottom but wrap bottom to top, so the topmost decorator runs first at call time.
Frequently Asked Questions
Do browsers or Node.js run JavaScript decorators without a build step?
Are these the same decorators used in older Angular or NestJS code?
Conclusion
A logging decorator moves cross-cutting logging code out of your business logic and into one reusable function. Write the decorator once with the value and context signature, apply it above any method you want to trace, and it wraps that method the moment the class is defined. Reach for it when several classes need the same logging or timing behavior, and skip it for one-off scripts where a build step is not worth the setup.Using decorators for logging comes down to one function shaped as value and context that wraps a method or class to add tracing without editing the original code. It runs once at class definition time to produce a wrapped function, and that wrapper runs on every call afterward. Since no runtime executes this syntax natively yet, compile it with Babel or write it in TypeScript, where this style of decorator is supported out of the box. Once set up, the same decorator works across every class in a codebase, the same way a rate limiter can be applied as a reusable wrapper instead of copy-pasted logic.
More in this topic
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.
JS for Loop Syntax: A Complete Guide for Beginners
The for loop is the most common way to repeat code in JavaScript. Learn the three-part syntax, how each part works, and how to write clean, readable for loops.