Skip to content

Web Development · TypeScript

TypeScript Decorators in 2026: The Stabilized Proposal Worth Learning

TypeScript 5.0 shipped support for the TC39 Stage 3 decorators proposal. It's different from the old experimentalDecorators, more useful, and now the way to actually write decorators.

Anurag Verma

Anurag Verma

6 min read

TypeScript Decorators in 2026: The Stabilized Proposal Worth Learning

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.

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.

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.

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.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored