Skip to main content

decorator pattern

Decorator pattern wraps an object to add new behavior at runtime without modifying the original object or creating subclasses.

Theory

TL;DR

  • Analogy: adding toppings to a pizza. Each topping wraps the previous layer; the pizza stays the same underneath. You can combine toppings in any order.
  • Core difference: inheritance adds behavior at compile-time to the class; decorators add behavior at runtime to individual instances.
  • Decision rule: use decorators when you need to mix features dynamically, can't modify the original class, or want to avoid subclass explosion.

Quick example

typescript
// Original object - stays unchanged class Coffee { cost() { return 5; } description() { return "Coffee"; } } // Decorator wraps and extends class MilkDecorator { constructor(private coffee: Coffee) {} cost() { return this.coffee.cost() + 2; } description() { return this.coffee.description() + ", Milk"; } } const coffee = new Coffee(); const withMilk = new MilkDecorator(coffee); console.log(withMilk.cost()); // 7 console.log(withMilk.description()); // "Coffee, Milk"

MilkDecorator holds a reference to the original Coffee object, calls its methods, and adds its own logic on top. The original is untouched.

Key difference from inheritance

Inheritance creates a fixed hierarchy at compile-time. CoffeeWithMilk extends Coffee adds milk behavior to every instance of that subclass, permanently. Decorators wrap individual instances at runtime. You can apply MilkDecorator to one coffee and SugarDecorator to another, using the same classes.

This also solves subclass explosion. Without decorators you'd need CoffeeWithMilkAndSugar, CoffeeWithMilkAndCaramel, CoffeeWithSugarAndCaramel as separate classes. Three decorator classes cover every combination.

When to use

  • Third-party code: you can't modify the original class, so wrap it instead.
  • Dynamic combinations: MilkDecorator(SugarDecorator(coffee)) vs SugarDecorator(coffee) without changing any class definition.
  • Cross-cutting concerns: add logging, caching, or validation without touching core logic.
  • Feature flags: conditionally wrap objects based on config or environment.

How it works internally

The decorator keeps a reference to the wrapped object and implements the same interface. When a method is called on the decorator, it calls the wrapped object's method, adds its own logic before or after, and returns the result. This creates a chain where each layer intercepts calls and passes them down.

In JavaScript and TypeScript this happens through object composition at runtime. No class hierarchy changes. You're just passing objects into other objects.

Common mistakes

1. Not implementing the full interface

The decorator must expose all methods of the wrapped object. Skip one and callers get a runtime error.

typescript
// Wrong - missing methods break callers class LoggingDecorator { constructor(private obj: UserRepository) {} save() { console.log("saving"); return this.obj.save(); } // Missing: delete(), update(), find() } // Right - full interface preserved class LoggingDecorator implements UserRepository { constructor(private obj: UserRepository) {} save() { console.log("saving"); return this.obj.save(); } delete() { console.log("deleting"); return this.obj.delete(); } update() { console.log("updating"); return this.obj.update(); } find() { console.log("finding"); return this.obj.find(); } }

2. Mutating the wrapped object

Adding properties directly to this.obj defeats the purpose. The original gets polluted and you can't revert the decoration.

typescript
// Wrong - pollutes the original class CacheDecorator { constructor(private obj: DataService) { (this.obj as any).cache = new Map(); // don't do this } } // Right - decorator owns its own state class CacheDecorator implements DataService { private cache = new Map(); constructor(private obj: DataService) {} getData(key: string) { if (this.cache.has(key)) return this.cache.get(key); const data = this.obj.getData(key); this.cache.set(key, data); return data; } }

3. Ignoring decorator order

Order matters. Wrapping UppercaseDecorator around MetadataDecorator breaks because toUpperCase() receives an object, not a string. In Express, withCache(withAuth(handler)) means auth runs inside the cache check. Reverse the order and you cache unauthenticated requests.

typescript
// Works: uppercase first, then metadata wraps it const v1 = new MetadataDecorator(new UppercaseDecorator(processor)); // { value: "HELLO", timestamp: 1713110126000 } // Breaks: UppercaseDecorator receives an object, not a string const v2 = new UppercaseDecorator(new MetadataDecorator(processor)); // TypeError: result.toUpperCase is not a function

4. Using decorators when inheritance is the right fit

If behavior belongs on every instance of a class, inheritance is simpler. Decorators are for optional, per-instance behavior.

typescript
// Right: every Dog barks - use inheritance class Animal { move() {} } class Dog extends Animal { bark() {} } // Right: only some dogs are trained - use a decorator const trainedDog = new TrainedDecorator(new Dog());

5. Deep chains on hot paths

Each decorator layer adds a function call. Five decorators is fine. Fifty on every incoming request is measurable overhead. Profile before assuming decoration is free.

Real-world usage

  • React: Higher-order components (HOCs) like withRouter, connect from Redux wrap components to inject props.
  • Express/Node.js: Middleware functions. withAuth(withLogging(handler)) is a decorator chain on request handlers.
  • Python: The @decorator syntax. @lru_cache, @property, @staticmethod are all decorators.
  • Java Streams: stream().filter().map().collect() chains decorators on collections.
  • TypeScript/JavaScript: Function composition with Lodash _.compose() or Ramda stacks transformations.

In my experience, the pattern clicks once you see Express middleware. Every app.use() call is a decorator in disguise.

Follow-up questions

Q: How does the decorator pattern differ from the Strategy pattern?


A: Strategy swaps the algorithm inside an object (one strategy at a time). Decorator stacks multiple behaviors on top of what the object already does. Strategy changes what the object does; decorator adds extra logic around it.

Q: What happens when you apply multiple decorators to the same object?


A: Each decorator wraps the previous one, forming a chain. Calls flow through each layer in reverse order (last applied runs first). This is why order matters, especially when decorators have side effects.

Q: Can you use decorators with stateful objects?


A: Yes. The decorator wraps the object, not its state. If the wrapped object's state changes, the decorator sees those changes. The decorator's own state (like a cache) is kept separate.

Q: How do you preserve type safety in TypeScript with decorator chains?


A: Declare an interface and make each decorator implement it. Or use generics: class Logger<T extends UserRepository> ensures TypeScript tracks what methods are available. Without this, you lose autocomplete after the first wrap.

Q: (Senior) How would you build a decorator that works with both sync and async methods?


A: Check whether the return value is a Promise. If it is, chain .then() to intercept the resolved value. If not, modify synchronously.

typescript
class TransformDecorator implements DataProcessor { constructor(private obj: DataProcessor) {} process(...args: any[]) { const result = this.obj.process(...args); if (result instanceof Promise) { return result.then(value => this.transform(value)); } return this.transform(result); } private transform(value: string) { return value.toUpperCase(); } }

Examples

Basic: Coffee with toppings

The classic example. Each decorator wraps the previous object and both use the same interface, so they're interchangeable from the caller's perspective.

typescript
interface Coffee { cost(): number; description(): string; } class SimpleCoffee implements Coffee { cost() { return 5; } description() { return "Coffee"; } } class MilkDecorator implements Coffee { constructor(private coffee: Coffee) {} cost() { return this.coffee.cost() + 2; } description() { return this.coffee.description() + ", Milk"; } } class SugarDecorator implements Coffee { constructor(private coffee: Coffee) {} cost() { return this.coffee.cost() + 1; } description() { return this.coffee.description() + ", Sugar"; } } const plain = new SimpleCoffee(); const latte = new MilkDecorator(plain); const sweetLatte = new SugarDecorator(latte); console.log(sweetLatte.cost()); // 8 console.log(sweetLatte.description()); // "Coffee, Milk, Sugar"

Three classes cover every combination. No CoffeeWithMilkAndSugar subclass needed.

Intermediate: Express middleware as decorators

Express middleware is a real-world decorator chain. Each function wraps the next handler and decides whether to pass the call through.

typescript
type Handler = (req: Request, res: Response) => void; function getUser(req: Request, res: Response) { const user = db.findUser(req.params.id); res.json(user); } function withAuth(handler: Handler): Handler { return (req, res) => { if (!req.headers.authorization) { return res.status(401).json({ error: "Unauthorized" }); } return handler(req, res); }; } function withLogging(handler: Handler): Handler { return (req, res) => { console.log(`${req.method} ${req.path}`); return handler(req, res); }; } // Last applied runs first const secureHandler = withLogging(withAuth(getUser)); app.get("/users/:id", secureHandler); // Request flow: logging -> auth check -> original handler

withLogging wraps withAuth, which wraps getUser. Each layer handles one concern and doesn't touch the others.

Advanced: Async-aware decorator

A decorator that handles both sync and async return values without the caller knowing the difference.

typescript
interface DataProcessor { process(data: string): string | Promise<string>; } class RawProcessor implements DataProcessor { process(data: string) { return data; } } class UppercaseDecorator implements DataProcessor { constructor(private processor: DataProcessor) {} process(data: string) { const result = this.processor.process(data); // Handle both sync and async transparently if (result instanceof Promise) { return result.then(value => value.toUpperCase()); } return result.toUpperCase(); } } // Works with sync processor const syncDecorated = new UppercaseDecorator(new RawProcessor()); console.log(syncDecorated.process("hello")); // "HELLO" // Works with async processor too class AsyncRawProcessor implements DataProcessor { process(data: string) { return Promise.resolve(data); } } const asyncDecorated = new UppercaseDecorator(new AsyncRawProcessor()); asyncDecorated.process("hello").then(console.log); // "HELLO"

The decorator doesn't need to know whether the inner processor is sync or async. It checks at runtime and handles both paths.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?