JavaScript Decorators in 2026: 3 Patterns to Retire Your Higher-Order Functions

2026-05-20 · Nico Brandt

For about a decade, “JavaScript decorators are coming soon” has been one of those things you nod at and forget. It stopped being true in January 2025. Chrome shipped them. The TC39 proposal is stable. TypeScript 5.0+ understands the new syntax. And your trusty higher-order function patterns — the withLogging wrappers, the requireAuth factories, the cachingDecorator you copied from javascript.info — are no longer the only option for class methods. Three patterns are worth migrating today. The rest of your HOFs can stay where they are.

Are JavaScript Decorators Native Yet? (Answer: Yes, Mostly)

Yes. Chrome shipped native JavaScript decorators in January 2025. The TC39 decorators proposal has been at Stage 3 since March 2022, TypeScript 5.0+ supports the Stage 3 syntax, Firefox is prototyping, and Safari has not committed to a timeline. For production code today, native works in Chromium; everywhere else, ship through Babel or TypeScript.

If you last read about decorators in 2022, the API changed. The old Stage 2 signature handed you (target, propertyKey, descriptor) and asked you to mutate the descriptor in place. The Stage 3 signature is cleaner: (value, context) — the method itself, plus a context object with kind, name, addInitializer, and a few other helpers. Code from 2022 articles will not run.

The other thing to know: decorators are class-only. The current proposal applies to classes and their members — methods, accessors, fields. A separate January 2026 proposal from Ron Buckton would extend decorators to standalone functions and object literal elements, but it has not shipped. If you’re wrapping bare functions in 2026, higher-order functions are still your tool. Don’t wait for the function decorator proposal — write the HOF.

Settled? Good. Now let’s talk about what your class methods are missing.

Pattern 1: Method Logging — From Wrapper Function to @log

Every codebase has a logging HOF. Mine looked like this for years:

const withLogging = (fn) => function (...args) {
  console.log(`→ ${fn.name}(${JSON.stringify(args)})`);
  const result = fn.apply(this, args);
  console.log(`← ${fn.name}`, result);
  return result;
};

class UserService {
  constructor() {
    this.fetchUser = withLogging(this.fetchUser.bind(this));
  }
  fetchUser(id) { /* ... */ }
}

It works. It also asks for things a wrapper shouldn’t have to ask for: manual rebinding so this survives, mutation of the instance inside the constructor, and a method name that only stays correct because fn.name happens to be set. Add a second method to wrap, and the constructor turns into a wiring diagram.

The Stage 3 version is a function, same as before — but it knows it’s a method:

function log(originalMethod, context) {
  return function (...args) {
    console.log(`→ ${String(context.name)}(${JSON.stringify(args)})`);
    const result = originalMethod.call(this, ...args);
    console.log(`← ${String(context.name)}`, result);
    return result;
  };
}

class UserService {
  @log
  fetchUser(id) { /* ... */ }
}

Two things changed. First, the declaration is at the call site, where it belongs — you can see at a glance that fetchUser is logged. Second, context.name gives you the method name for free, no Object.defineProperty patching to keep .name honest. The constructor is empty. Rebinding is automatic.

If fetchUser were a standalone function instead of a method, none of this would apply — the HOF stays. Decorators are not a universal HOF replacement. They are a cleaner HOF for the specific case of class methods.

Logging is the easy case. It observes. The next pattern needs to interrupt.

Pattern 2: Access Control — @requireAuth Beats Manual Role Checks

Authorization is where HOFs start to fight you. Here’s the version most teams have shipped at some point:

const requireAuth = (role) => (fn) => function (...args) {
  if (this.user?.role !== role) throw new Error('Unauthorized');
  return fn.call(this, ...args);
};

class AdminPanel {
  constructor() {
    this.deleteUser = requireAuth('admin')(this.deleteUser);
    this.suspendUser = requireAuth('admin')(this.suspendUser);
  }
  deleteUser(id) { /* ... */ }
  suspendUser(id) { /* ... */ }
}

The role requirement is now wired up in the constructor, far from the method it protects. A code reviewer scanning deleteUser has to scroll up to see whether it’s guarded. A grep for “unprotected admin actions” requires you to remember which side of the wiring to grep for.

The decorator version puts the rule where the method is:

function requireAuth(role) {
  return function (originalMethod, context) {
    if (context.kind !== 'method') {
      throw new Error('@requireAuth only applies to methods');
    }
    return function (...args) {
      if (this.user?.role !== role) throw new Error('Unauthorized');
      return originalMethod.call(this, ...args);
    };
  };
}

class AdminPanel {
  @requireAuth('admin')
  deleteUser(id) { /* ... */ }

  @requireAuth('admin')
  suspendUser(id) { /* ... */ }
}

The context.kind guard is the kind of small thing senior code does. If a teammate puts @requireAuth on a class or an accessor by accident, it fails loudly instead of producing a silently broken handler. This is the same instinct that drives code review best practices — fail at definition time, not in production.

The bigger win is searchability. git grep '@requireAuth' returns every protected action in the codebase. Nobody had to remember to grep the constructor.

Two patterns down — both relatively stateless wrappers. The third pattern wants memory.

Pattern 3: Memoization — Replacing the Classic cachingDecorator HOF

If you’ve read the javascript.info tutorial, you know the canonical caching decorator: take a function, build a Map, return a wrapper. It works for pure standalone functions. It gets awkward the moment the cache needs to be per-instance:

function memoize(originalMethod, context) {
  const cache = new WeakMap();
  return function (...args) {
    const key = JSON.stringify(args);
    let perInstance = cache.get(this);
    if (!perInstance) {
      perInstance = new Map();
      cache.set(this, perInstance);
    }
    if (perInstance.has(key)) return perInstance.get(key);
    const result = originalMethod.call(this, ...args);
    perInstance.set(key, result);
    return result;
  };
}

class Reports {
  @memoize
  computeReport(year) { /* expensive */ }
}

The subtle win is the WeakMap keyed on this. The cache lives as long as the instance does and gets garbage-collected with it. The HOF version had to choose between a single shared cache (memory leak on long-lived processes) or manual cleanup (a footgun in disguise).

The honest caveat is the same one the HOF had: JSON.stringify is a cheap, imperfect key. If your method takes object arguments where reference identity matters, swap the key strategy — a WeakMap of arg objects, or a content hash. The decorator didn’t fix that problem; it just stopped being the part you write twice.

Three patterns, three migrations. But if you’re on TypeScript with experimentalDecorators turned on, you’re about to hit a fork in the road.

The TypeScript Split: Migrating from experimentalDecorators to Stage 3

TypeScript 5.0 added Stage 3 decorator support alongside the legacy experimentalDecorators flag. They coexist, but they use different signatures, and a project can only use one at a time per file path.

The old signature received a property descriptor:

function log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) { /* ... */ };
  return descriptor;
}

The new signature receives the method itself:

function log(value: Function, context: ClassMethodDecoratorContext) {
  return function (this: any, ...args: any[]) { /* ... */ };
}

Migration is mechanical for the common cases. Remove experimentalDecorators and emitDecoratorMetadata from tsconfig.json. Rewrite each decorator from (target, key, descriptor) to (value, context). Replace descriptor.value = ... with return function(...) {...}. Where you used Reflect.metadata, you’ll need the separate Stage 3 decorator-metadata proposal — and a framework that has adopted it. Most haven’t.

That’s the catch. Parameter decorators — the ones NestJS and TypeORM use on constructor arguments like @Inject and @Column — are not in the Stage 3 proposal. If your project leans on them, you can’t migrate the rest of your decorators without leaving parameter decorators behind. For now, the pragmatic call is: keep experimentalDecorators on for frameworks that need it, and wait for them to ship native support. If you’re using TypeScript on a greenfield project without those frameworks, start on Stage 3 today.

You’ve now got the rules for old code. The next question is what to write on new code.

When to Use Decorators vs Higher-Order Functions (The Honest Rule)

The decision is simpler than the SERP wants you to believe.

Reach for a decorator when: the target is a class method, the wrapping is declarative (logging, auth, validation, caching), the same wrapping shows up three or more times in your codebase, and you want the rule visible at the call site for code review.

Stick with a higher-order function when: the target is a standalone function (decorators don’t apply — class-only in 2026), you’re composing pipelines at runtime (data.pipe(map(fn), filter(fn))), or the wrapping is one-off and inlining it would be clearer.

Don’t cargo-cult. A wrapper used once isn’t worth a decorator. Decorators pay off through repetition.

Browser reality check: native @decorator works in Chrome and Chromium-based browsers as of January 2025. For Firefox, Safari, and older Chrome, compile through Babel (@babel/plugin-proposal-decorators with version: '2023-11') or TypeScript 5.0+. Both transpile to ES2015 wrappers — production runtime cost is effectively zero, the same as the HOF you would have written by hand.

The mental model that helps: decorators are higher-order functions the language now understands. Same idea, less plumbing, applied at class-definition time instead of at construction time.

The Bottom Line

So — are your HOFs still the right tool? For class methods, mostly no. The three patterns above (logging, access control, memoization) read cleaner as decorators, code-review better, and stop polluting your constructors with wiring. For standalone functions and runtime pipelines, HOFs stay. They were never the problem.

What to do this week: grep your codebase for withLogging, requireAuth, memoize, and any other wrapper-factory pattern you’ve shipped. The ones applied to class methods are migration candidates. Follow our safe refactoring workflow to validate each migration before shipping. The ones applied to standalone functions stay where they are.

If you’re on TypeScript, bump to 5.0+ and try one decorator on a new feature before you touch legacy code. If you’re on plain JS in Chrome, just ship it. If you target older browsers, Babel handles it with no runtime tax.

This isn’t a rewrite-everything moment. It’s a stop-reaching-for-HOF-when-a-decorator-is-cleaner moment. Use them where they fit. Leave the rest alone.