Articles

TypeScript Decorators Explained: Syntax and Use Cases

TypeScript decorators attach reusable behavior to classes and members with an @ syntax. How class, method, and field decorators work, with real examples.

Takina Takina · · 4 min read
A dark-themed code editor showing TypeScript source

Decorators are functions that attach behavior to a class, method, accessor, or field at the point it’s defined, using an @expression placed directly above the declaration. Instead of editing the class body to add logging, validation, or dependency injection, you wrap the declaration with a decorator and let it modify or replace the thing it’s attached to — keeping cross-cutting concerns out of the class’s core logic.

Basic syntax

A decorator is just a function, applied with @:

class Widget {
  @log
  render() {
    return "<div>widget</div>";
  }
}

Here, log receives the original render method and returns a replacement — typically a wrapped version that runs some behavior before or after calling through to the original:

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

Where decorators can be applied

Decorators can target several different declaration kinds, each receiving a slightly different shape of input:

Decorator targetApplied toTypical use
ClassThe class constructor itselfRegistering the class, adding static metadata
MethodAn instance or static methodLogging, memoization, access control
AccessorA get/set pairValidation, computed caching
FieldAn instance propertyDefault values, observability

A class decorator, for example, receives the class itself and can return a new class that extends or replaces it:

function sealed(target: Function) {
  Object.seal(target);
  Object.seal(target.prototype);
}

@sealed
class Config {
  readonly env = "production";
}

Decorator factories

Most real-world decorators need configuration, so they’re written as a function that returns a decorator rather than being one directly — a decorator factory:

function retry(times: number) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    return async function (this: unknown, ...args: unknown[]) {
      for (let attempt = 0; attempt < times; attempt++) {
        try {
          return await target.apply(this, args);
        } catch (err) {
          if (attempt === times - 1) throw err;
        }
      }
    };
  };
}

class ApiClient {
  @retry(3)
  async fetchUser(id: string) {
    /* ... */
  }
}

The extra parentheses — @retry(3) instead of @retry — are the tell that you’re looking at a factory: the outer call configures the decorator, and the function it returns is what actually gets applied to fetchUser.

Common use cases

  • Logging and tracing — wrap a method to record when it’s called and with what arguments.
  • Memoization — cache a method’s return value keyed on its arguments.
  • Validation — check a field or parameter against a rule before allowing the underlying logic to run.
  • Dependency injection — frameworks that use decorators to declare which services a class constructor needs, resolving them automatically.
  • ORM mapping — annotating a class’s fields to describe how they map to database columns, letting the ORM generate schema and queries from the class definition alone.

Why not just write a wrapper function?

You could get similar behavior by manually wrapping a method after the fact — reassigning render to a logged version somewhere in a constructor, for instance. Decorators exist because that approach doesn’t scale: it separates the behavior from the declaration it applies to, it’s easy to forget when adding a new method, and it doesn’t compose cleanly when several behaviors need to stack on the same member. Writing @log @retry(3) @cache above a method reads as a declaration of everything that method does, in order, right where the method is defined — a manual wrapper buried in a constructor doesn’t give you that at a glance.

Field decorators work similarly, running when the class is defined rather than when an instance is constructed, which lets a decorator register metadata about a field — for validation rules or serialization behavior, for example — before any instance exists:

function required(target: undefined, context: ClassFieldDecoratorContext) {
  context.addInitializer(function (this: any) {
    if (this[context.name] === undefined) {
      throw new Error(`${String(context.name)} is required`);
    }
  });
}

class User {
  @required
  email!: string;
}

A note on decorator history

Decorators have existed in TypeScript for a long time behind an experimentalDecorators compiler flag, using a calling convention modeled on an early proposal. TypeScript has since added support for the newer, standardized version of decorators that doesn’t require any flag and follows the shape used in the examples above. The two versions aren’t interchangeable — a decorator written for one convention won’t work under the other — so check which mode a codebase or library targets before copying a decorator implementation from documentation.

Combining multiple decorators

Decorators stack, and order matters. Multiple decorators on the same declaration are applied bottom-up but run top-down when the wrapped function is finally called — the decorator closest to the declaration wraps first:

class Service {
  @log
  @retry(3)
  async fetchData() {
    /* ... */
  }
}

Here, retry wraps the original fetchData first, and log then wraps the retry-enabled version. When fetchData is called, log runs first, delegating to the retry logic underneath. Getting this ordering backwards is a common source of confusion — if logging needs to capture every individual attempt rather than just the final outcome, the decorators need to be swapped.

The takeaway

A decorator is a function that intercepts a class, method, accessor, or field at definition time and can wrap, replace, or annotate it. Decorator factories add configuration by returning the actual decorator from an outer function call. They’re most valuable for cross-cutting concerns — logging, retries, validation, dependency injection — that would otherwise clutter every class that needs them. Pair them with TypeScript generics and utility types when a decorator needs to preserve or transform the type of what it wraps, and see getting started with TypeScript if you’re new to the language before reaching for decorators.

Takina Takina · · 4 min read

TypeScript Abstract Classes, Explained

Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.

#TypeScript #JavaScript #Web Development
Takina Takina · · 3 min read

TypeScript's never Type, Explained

never represents values that can't exist — it marks unreachable code, exhaustive switches, and functions that always throw or loop forever.

#TypeScript #JavaScript #Web Development
Takina Takina · · 4 min read

TypeScript readonly Modifiers Explained

TypeScript's readonly keyword blocks reassignment at compile time for properties, arrays, and tuples — with no runtime enforcement at all.

#TypeScript #JavaScript #Web Development