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
// 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))vsSugarDecorator(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.
// 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.
// 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.
// 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 function4. 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.
// 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,connectfrom Redux wrap components to inject props. - Express/Node.js: Middleware functions.
withAuth(withLogging(handler))is a decorator chain on request handlers. - Python: The
@decoratorsyntax.@lru_cache,@property,@staticmethodare 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.
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.
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.
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 handlerwithLogging 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.
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 readyA concise answer to help you respond confidently on this topic during an interview.