Skip to main content

Decorator — A Pattern Distinct from TC39 Decorators

This chapter settles the distinction from the identically named language feature first, then covers when to reach for a higher-order function instead.

About the "Decorator" this chapter covers

TypeScript has a language feature called Decorators, written like @log. It is a different concept from the GoF Decorator pattern this chapter covers. They only share a name; the problem they set out to solve and the way you write them both differ.

  • Decorators, the language feature — syntax for annotating a class or a member and processing its definition in a reusable way. It rests on a proposal to ECMAScript (Stage 2.7 as of August 2026) and is not yet part of the language specification1. Since TypeScript 5.0 you can write it without a compiler flag2. On this site, Inheritance and interfaces covers it, including how it differs from the older implementation.
  • The GoF Decorator pattern — a design technique that wraps an object in another object of the same shape and adds behavior without changing the shape seen from outside. It is not about syntax.

You can write the GoF Decorator pattern using the Decorators language feature, but you do not have to. The names merely overlap; either one can be written without the other. This chapter covers only the GoF Decorator pattern.

The problem this pattern set out to solve

GoF catalogs Decorator as a pattern that dynamically adds to or overrides the behavior of an existing object3.

Expressing combinations of features through inheritance takes one class per combination. With three features — retrying, logging, and caching — there are seven combinations that include at least one of them, and that many classes. Decorator solves this by stacking wrappers that share the same shape. Change the wrapping order and the combination changes, so the number of classes stays equal to the number of features.

Writing it straightforwardly with classes

The subject here is fetching JSON. You fix a shared shape, and the wrappers implement that shape too.

interface JsonSource {
load(url: string): Promise<unknown>;
}

class HttpJsonSource implements JsonSource {
async load(url: string): Promise<unknown> {
const response = await fetch(url);
return response.json();
}
}

// The base for wrappers. Holds one inner value and implements the same shape itself
// Pass-through is written here once. Concrete decorators override only what they want to change
class JsonSourceDecorator implements JsonSource {
constructor(protected readonly inner: JsonSource) {}

load(url: string): Promise<unknown> {
return this.inner.load(url);
}
}

class RetryingJsonSource extends JsonSourceDecorator {
constructor(
inner: JsonSource,
private readonly attempts: number,
) {
super(inner);
}

override async load(url: string): Promise<unknown> {
let lastError: unknown = new Error('attempts must be >= 1');
for (let i = 0; i < this.attempts; i++) {
try {
return await this.inner.load(url);
} catch (error) {
lastError = error;
}
}
throw lastError;
}
}

class LoggingJsonSource extends JsonSourceDecorator {
override async load(url: string): Promise<unknown> {
const startedAt = performance.now();
try {
return await this.inner.load(url);
} finally {
console.log(`${url}: ${Math.round(performance.now() - startedAt)}ms`);
}
}
}

// The wrapping order decides the combination
const source: JsonSource = new LoggingJsonSource(
new RetryingJsonSource(new HttpJsonSource(), 3),
);

It works as intended. But the only lines that actually write behavior are the bodies of load; the rest goes to class declarations, constructors, and passing values up through super.

Replacing it with language features

When what you wrap is a single function, the wrapper can be a function too — a function that takes a function and returns a function, in other words a higher-order function.

type JsonFetch = (url: string) => Promise<unknown>;

const httpFetch: JsonFetch = async (url) => {
const response = await fetch(url);
return response.json();
};

function withRetry(inner: JsonFetch, attempts: number): JsonFetch {
return async (url) => {
let lastError: unknown = new Error('attempts must be >= 1');
for (let i = 0; i < attempts; i++) {
try {
return await inner(url);
} catch (error) {
lastError = error;
}
}
throw lastError;
};
}

function withLogging(inner: JsonFetch): JsonFetch {
return async (url) => {
const startedAt = performance.now();
try {
return await inner(url);
} finally {
console.log(`${url}: ${Math.round(performance.now() - startedAt)}ms`);
}
};
}

// The nesting is exactly the same as in the class version
const load: JsonFetch = withLogging(withRetry(httpFetch, 3));

Neither the nested structure nor the significance of the wrapping order changes. What went away are the interface declaration, the abstract class, the constructors, and the passing up through super. Only the scaffolding of the wrapper is gone; the behavior itself is still there.

How this series judges it

Verdict: conditional. It splits on how many methods the wrapped thing has.

  • One method (effectively a function) means a higher-order function — the rewrite above applies directly. One function type such as JsonFetch is all the typing you need, and no wrapper needs a type declaration of its own.
  • Wrapping a contract with several methods means a class or an object — with function composition, you end up threading every method in the contract through by hand. Even when you want to touch just one of five methods, you still have to write the pass-through for the other four. With a class, you write the pass-through once in a base decorator class, and every concrete decorator after that gets it through inheritance. The total does not drop to zero; the difference is that you do not repeat it once per wrapper.

The dividing line is not "do you want to add behavior" but "how many methods do you have to pass through". Once the pass-through code outweighs the actual change, a class reads better.

What would overturn this judgment

Pass-through code can be handled dynamically with the built-in Proxy. That keeps the wrapper down to one no matter how many methods there are, which changes the judgment above. With Proxy, though, type checking on some of the values you return from an intercepted operation stops working. That is covered in Proxy.

One more thing: wrapping with a higher-order function can drop the original function's name from stack traces. In places where tracing callers during an incident matters, that becomes a practical factor in the decision.

How this relates to existing articles

  • Inheritance and interfaces covers Decorators as a language feature. The differences between the new implementation and the older one (--experimentalDecorators), Symbol.metadata, and the situation with frameworks such as Angular and NestJS that need the older implementation — that article is the authority on all of it. This chapter covers a different concept that shares the name, so the contents do not overlap. For the proposal's stage, treat the TC39 README cited in this chapter's footnote as the primary source.
  • Classes and interfaces covers the idea of preferring composition over inheritance. Decorator is a representative example of stacking features through composition.

Footnotes

  1. Source: Decorators (TC39 proposal), the README. It states "Stage: 2.7".

  2. Source: TypeScript 5.0 (release notes), "Decorators". That section states, "Decorators are an upcoming ECMAScript feature that allow us to customize classes and their members in a reusable way." On whether the flag is required, it states, "without the flag, decorators will now be valid syntax for all new code."

  3. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994). Decorator is catalogued there as a structural pattern. The summary of the intent is this guide's own; we have not checked it against the original text.