Skip to content

Web Development · TypeScript

TypeScript Decorators in 2026: What Changed and Why

TypeScript 5.0 replaced experimentalDecorators with the TC39 Stage 3 proposal: different syntax, more capable, now the standard way to write decorators.

Anurag Verma

Anurag Verma

8 min read

TypeScript Decorators in 2026

Sponsored

Share

TypeScript has had decorator support since version 1.5, but that support was behind the experimentalDecorators flag, based on an early draft of the TC39 proposal that the committee later overhauled. The result was years of instability: decorators worked in Angular and NestJS, but the syntax was non-standard and the behavior subtle.

TypeScript 5.0, released in March 2023, shipped the new decorator implementation based on the mature Stage 3 proposal. The two systems aren’t compatible. The new decorators do more, work differently, and don’t need any compiler flags. If you’re tracking newer language changes generally, see our rundown of what shipped in TypeScript 5.8 and the TypeScript 7.0 migration playbook for what came after.

By 2026, the new decorators are what you should be writing. Here’s how they work.

The Old vs New

The experimentalDecorators flag enables the legacy system. It’s still the default in many Angular and NestJS projects because those frameworks haven’t fully migrated. If you’re maintaining code that uses experimentalDecorators: true in its tsconfig, that’s the legacy path.

New decorators work without any tsconfig changes on TypeScript 5.0+:

// Old (requires experimentalDecorators: true)
@Component({ selector: 'app-root' })  // Angular-style

// New (no flag needed)
@logCalls
class UserService { ... }

The new proposal also changed what decorators receive and what they can return, which affects how you write them.

Class Decorators

A class decorator receives a constructor and a context object. It can return a new class that wraps or replaces the original.

function singleton<T extends { new(...args: any[]): {} }>(
  target: T,
  context: ClassDecoratorContext
) {
  let instance: InstanceType<T> | undefined;

  return class extends target {
    constructor(...args: any[]) {
      if (instance) return instance;
      super(...args);
      instance = this as InstanceType<T>;
    }
  } as T;
}

@singleton
class DatabaseConnection {
  constructor(public readonly url: string) {}
}

const a = new DatabaseConnection("postgres://localhost/mydb");
const b = new DatabaseConnection("postgres://localhost/other");
console.log(a === b); // true

The context parameter carries metadata about the decorated item: its name, whether it’s static, and access to addInitializer for running setup code after the class is defined.

Method Decorators

Method decorators receive the method function and a context object. The most common use case is wrapping the method with behavior:

function logCalls(
  target: Function,
  context: ClassMethodDecoratorContext
) {
  const name = String(context.name);
  return function (this: unknown, ...args: unknown[]) {
    console.log(`Calling ${name} with`, args);
    const result = target.apply(this, args);
    console.log(`${name} returned`, result);
    return result;
  };
}

class OrderService {
  @logCalls
  async createOrder(userId: string, items: string[]) {
    // ...
    return { id: "ord_123", userId, items };
  }
}

A more useful example is a retry decorator:

function retry(attempts: number) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    return async function (this: unknown, ...args: unknown[]) {
      let lastError: unknown;
      for (let i = 0; i < attempts; i++) {
        try {
          return await target.apply(this, args);
        } catch (err) {
          lastError = err;
          if (i < attempts - 1) {
            await new Promise((resolve) => setTimeout(resolve, 2 ** i * 100));
          }
        }
      }
      throw lastError;
    };
  };
}

class PaymentService {
  @retry(3)
  async chargeCard(token: string, amount: number) {
    const response = await fetch("https://api.stripe.com/v1/charges", {
      method: "POST",
      body: new URLSearchParams({ source: token, amount: String(amount) }),
    });
    if (!response.ok) throw new Error(`Charge failed: ${response.status}`);
    return response.json();
  }
}

The @retry(3) version is a decorator factory: retry(3) returns the actual decorator. This is the pattern for parameterized decorators.

Accessor Decorators

The new proposal added a decorator for class fields that use the accessor keyword. Legacy decorators had limited field support; this is the fix.

function validated(min: number, max: number) {
  return function (
    target: ClassAccessorDecoratorTarget<unknown, number>,
    context: ClassAccessorDecoratorContext
  ): ClassAccessorDecoratorResult<unknown, number> {
    return {
      get() {
        return target.get.call(this);
      },
      set(value: number) {
        if (value < min || value > max) {
          throw new RangeError(
            `${String(context.name)} must be between ${min} and ${max}`
          );
        }
        target.set.call(this, value);
      },
    };
  };
}

class Product {
  @validated(0, 10000)
  accessor price = 0;

  @validated(1, 1000)
  accessor quantity = 1;
}

const product = new Product();
product.price = 99.99;    // Fine
product.price = -5;       // Throws RangeError

accessor is a new class field syntax that generates a getter and setter backed by a private storage slot. Decorator factories on accessor fields receive and return the get/set pair, which is why they can intercept reads and writes.

Memoization with Accessor Decorators

Caching the result of an expensive computation:

function memoize(
  target: ClassGetterDecoratorContext["addInitializer"] extends Function
    ? never
    : Function,
  context: ClassGetterDecoratorContext
) {
  const cache = new WeakMap<object, unknown>();
  return function (this: object) {
    if (!cache.has(this)) {
      cache.set(this, (target as Function).call(this));
    }
    return cache.get(this);
  };
}

class ReportGenerator {
  constructor(private data: number[]) {}

  @memoize
  get summary() {
    // Expensive calculation
    return {
      sum: this.data.reduce((a, b) => a + b, 0),
      avg: this.data.reduce((a, b) => a + b, 0) / this.data.length,
    };
  }
}

The WeakMap uses the instance as the key, so each instance gets its own cache slot. The reference is weak, so instances can be garbage collected when no longer needed.

Using Metadata

The context.metadata property lets decorators attach information to the class, which other decorators or runtime code can read:

function required(target: undefined, context: ClassFieldDecoratorContext) {
  context.metadata[context.name as string] = { required: true };
}

function validate(instance: object): string[] {
  const errors: string[] = [];
  const metadata = (instance.constructor as any)[Symbol.metadata];
  if (!metadata) return errors;

  for (const [field, rules] of Object.entries(metadata) as [string, any][]) {
    if (rules.required && !(instance as any)[field]) {
      errors.push(`${field} is required`);
    }
  }
  return errors;
}

class UserForm {
  @required
  name = "";

  @required
  email = "";

  bio = ""; // Not required
}

const form = new UserForm();
console.log(validate(form)); // ["name is required", "email is required"]

This is how validation libraries can be built on top of the new decorator API. One gotcha worth knowing: Symbol.metadata itself needs a runtime that supports it, or a small polyfill, since older JavaScript engines predate the symbol. Most current Node.js and browser releases already ship it, but double-check your target runtime before relying on context.metadata in a library meant to run in older environments.

Migrating an Existing Decorator

Moving a decorator from the legacy system to the new one usually means rewriting its signature, not just its logic. A legacy method decorator receives (target, propertyKey, descriptor) and mutates or returns a PropertyDescriptor; a new one receives (target, context) and returns a replacement function directly:

// Legacy (experimentalDecorators: true)
function logCallsLegacy(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;
  descriptor.value = function (...args: unknown[]) {
    console.log(`Calling ${propertyKey}`);
    return original.apply(this, args);
  };
  return descriptor;
}

// New (TypeScript 5.0+, no flag)
function logCalls(target: Function, context: ClassMethodDecoratorContext) {
  const name = String(context.name);
  return function (this: unknown, ...args: unknown[]) {
    console.log(`Calling ${name}`);
    return target.apply(this, args);
  };
}

The new version no longer touches a PropertyDescriptor directly — it works with the method function itself and gets the property name from context.name instead of a separate parameter. Decorators that relied on Reflect.metadata (common in older dependency-injection libraries) need the most rework, since the new proposal’s context.metadata is a different mechanism and isn’t a drop-in replacement. If a project isn’t ready to migrate yet, experimentalDecorators and the new decorators can’t be mixed in the same file, so migration has to happen one file at a time, not one decorator at a time.

The NestJS and Angular Situation

Both frameworks still use experimentalDecorators by default in mid-2026, because their entire decorator system was built on the legacy proposal. NestJS has been working on new-decorators support, and new projects should follow their migration guides when available.

If you’re starting a new NestJS or Angular project, follow the framework’s current defaults. Don’t force-enable new decorators before the framework is ready. You’ll get behavior mismatches.

For greenfield TypeScript projects that aren’t framework-dependent, the new decorators are available now with no configuration.

What’s Worth Decorating

Decorators add value when you have cross-cutting behavior that applies to multiple methods or classes: logging, retries, caching, validation, authorization checks. They’re less useful for one-off behavior that only applies in one place. A regular function is simpler. For request-body or config validation specifically, a schema library like the one covered in our Zod in production guide is often a better fit than a hand-rolled @required decorator.

The pattern to avoid: decorators that do too much. A @authenticate decorator that validates tokens, checks permissions, and logs access is hard to test and debug. Better to decompose that into @requiresAuth, @requiresRole("admin"), and @logAccess, each doing one thing.

The stabilized proposal gives you a clean, interoperable API for this kind of metaprogramming. The old experimental system worked for Angular and NestJS, but the new one will serve TypeScript developers who aren’t tied to those frameworks.

Frequently asked questions

What's the difference between the old and new TypeScript decorators?
The old system, enabled with experimentalDecorators, was based on an early TC39 draft and required a compiler flag. The new system, standard since TypeScript 5.0, is based on the mature Stage 3 proposal, needs no flag, and changes what decorators receive and can return — the two are not compatible with each other.
Do I need to enable a flag to use the new decorators?
No. New decorators work out of the box on TypeScript 5.0 and later with no tsconfig changes. The experimentalDecorators flag is only needed for the legacy system that frameworks like Angular and NestJS still rely on.
Should I use the new decorators with NestJS or Angular?
Not yet, in most cases. Both frameworks still default to experimentalDecorators in mid-2026 because their decorator-based architecture was built on the legacy proposal. Follow each framework's own migration guidance rather than force-enabling new decorators, or you'll hit behavior mismatches.
What can decorators be used for in TypeScript?
Decorators are best suited to cross-cutting behavior that applies across multiple methods or classes, such as logging, retries, caching, validation, and authorization checks. For one-off behavior used in a single place, a plain function is usually simpler and easier to test.
What is the accessor keyword used for?
accessor is a new class field syntax that generates a getter and setter backed by a private storage slot. Decorators applied to an accessor field receive and can return the get/set pair, which is what lets them intercept reads and writes — useful for validation or memoization.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored