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.

9 min read

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 need a build step in 2026

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:

javascriptjavascript
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.

javascriptjavascript
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:

PropertyMeaning
kindWhat is being decorated: method, class, field, getter, setter, or accessor
nameThe method or field name as a string
staticTrue if the member is declared with the static keyword
privateTrue 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:

Logging decorator wrapping a method call

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:

bashbash
npm install --save-dev @babel/core @babel/cli @babel/plugin-proposal-decorators

Enable 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:

jsonjson
{
  "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:

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Do browsers or Node.js run JavaScript decorators without a build step?

No. Decorators are a Stage 3 TC39 proposal. As of 2026, no browser and no version of Node.js parses the decorator syntax natively. You need Babel or TypeScript 5 or later to compile decorators into plain JavaScript before they run.

Are these the same decorators used in older Angular or NestJS code?

No. Those frameworks use TypeScript's legacy experimentalDecorators, which pass a target, key, and property descriptor to the decorator function. The Stage 3 decorators in this article use a different signature, a value and a context object, and they do not require the experimentalDecorators flag.

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.