Decorator
In short: Decorator wraps an object in another one with the same shape and adds behaviour without touching the class; in JavaScript a function wraps another function the same way.
Problem
The menu that grows with every add-on
The coffee shop sells espresso for €2. A customer can add milk (+€0.40), syrup (+€0.60) and an extra shot (+€0.80). Someone writes a class for every combination:
class Espresso { cost() { return 2; }
description() { return "Espresso"; }}
class EspressoWithMilk extends Espresso { cost() { return super.cost() + 0.4; }
description() { return `${super.description()}, milk`; }}
class EspressoWithMilkAndSyrup extends EspressoWithMilk { cost() { return super.cost() + 0.6; }
description() { return `${super.description()}, syrup`; }}let drink = new Espresso();console.log(`${drink.description()}: €${drink.cost().toFixed(2)}`);drink = new EspressoWithMilk();console.log(`${drink.description()}: €${drink.cost().toFixed(2)}`);drink = new EspressoWithMilkAndSyrup();console.log(`${drink.description()}: €${drink.cost().toFixed(2)}`);Espresso: €2.00Espresso, milk: €2.40Espresso, milk, syrup: €3.00It works for the three drinks on the menu. But three add-ons make eight combinations, and with cappuccino and tea that's 24 classes. One more add-on doubles the count.
EspressoWithSyrup (without milk) can't inherit the syrup from EspressoWithMilkAndSyrup, because a class has only one parent. The syrup code gets copied, and when syrup gets more expensive, it changes in several places.And the customer wants syrup twice. There is no such class. A class for one, two and three syrups is not a solution.
The idea
Decorator: a wrapper with the same shape
An add-on isn't a kind of drink. It is something that wraps a drink: it takes the drink and adds its own part.
So they all share one shape: the methodscost() and description(). The espresso has them and knows its own price. Milk has them too, but keeps another drink in a field. When asked for the price, it asks the inner drink and adds €0.40. No interface is needed: any object with these two methods can be wrapped.This is the Decorator pattern. It has three roles:
- the common shape: the methods
cost()anddescription(); - the base: the espresso, which wraps nothing;
- the decorators: milk, syrup, the extra shot. Each keeps an inner drink, calls the same method on it and adds its own part.
Since the wrapper has the same shape as the drink, a wrapper can wrap a wrapper. Double syrup is two layers of syrup. The code that asks for the price doesn't know how many layers there are.
See it
Layer by layer
The espresso and two add-ons. Every add-on gets the inner drink in its constructor:
class Espresso { cost() { return 2; }
description() { return "Espresso"; }}
class Milk { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.4; }
description() { return `${this.inner.description()}, milk`; }}
class Syrup { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.6; }
description() { return `${this.inner.description()}, syrup`; }}The order is put together from the inside out: first the espresso, then every layer wraps what there is so far.
let drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);console.log(`${drink.description()}: €${drink.cost().toFixed(2)}`);Espresso, milk, syrup, syrup: €3.60let drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);console.log(`${drink.description()}: €${drink.cost().toFixed(2)}`);Step by step, in words
- The espresso is the base. It wraps nothing and knows its price: 2.00.
- Milk wraps the espresso and keeps it in its field inner. drink now points to the milk.
- Syrup wraps the milk. The milk and the espresso inside are not touched.
- Syrup once more: a second layer of the same class. That is the double syrup.
- cost() goes to the outer layer. It adds nothing yet: first it calls this.inner.cost(), and so does every layer on the way in.
- The espresso answers 2.00. On the way out every layer adds its part to what came from inside.
- 3.60 comes out of the outer layer. description() goes the same way and collects the words.
The call to cost() goes in layer by layer down to the espresso, and the sum grows on the way out. None of the three classes knows about the others.
See it
A new layer without touching the old ones
The extra shot and the happy hour discount are two new classes. The discount doesn't add, it multiplies everything inside by 0.9:
class ExtraShot { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.8; }
description() { return `${this.inner.description()}, extra shot`; }}
class Discount { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() * 0.9; }
description() { return `${this.inner.description()}, 10% off`; }}const order = new Discount(new ExtraShot(new Milk(new Espresso())));console.log(`${order.description()}: €${order.cost().toFixed(2)}`);Espresso, milk, extra shot, 10% off: €2.88Espresso, Milk and Syrup didn't change. The new layer goes around any drink, including one that is already wrapped.
You read new Discount(new ExtraShot(new Milk(new Espresso()))) from the inside out: an espresso, wrapped in milk, wrapped in a shot, wrapped in a discount. The order matters: the discount applies only to what is inside it.
Here nobody checks what gets wrapped. A wrapper around a piece of text is created without an error, and the program stops only when someone asks for the price:
const drink = new Milk("Espresso");console.log(drink.cost());TypeError: this.inner.cost is not a function The error points at the line in Milk, not at the line where the wrong order was put together. So every new wrapper is run at least once with a real drink.
See it
A wrapper around a method
The same idea works outside coffee too. The order service works, and now every call has to be visible: what was asked and what came back. The code of the service shouldn't be touched: it is tested, and the logs are temporary.
In JavaScript a function is a value, so the wrapper is a function that takes a function and returns a new function. logged(fn) returns an arrow function that logs, calls fn with the same arguments (...args) and returns its result:
function logged(fn) { return (...args) => { console.log(`[log] ${fn.name}(${args.join(", ")})`); const result = fn(...args); console.log(`[log] ${fn.name} returned ${result}`); return result; };}
let next = 1001;
const place = logged(function place(drink, quantity) { console.log(`Preparing: ${quantity} × ${drink}`); return next++;});This is the same pattern as the milk around the espresso, only the layer is around a function.
place("Cappuccino", 2);place("Espresso", 1);[log] place(Cappuccino, 2)Preparing: 2 × Cappuccino[log] place returned 1001[log] place(Espresso, 1)Preparing: 1 × Espresso[log] place returned 1002
The code that calls place doesn't know it is wrapped. You remove the logs by removing the wrapper. The same goes for measuring time, for a cache or for a retry after an error: each one is a separate layer.
More precisely, if you are curious
For classes there is a proposal for an @decorator syntax, which TypeScript already supports. Node 22 doesn't have it yet, so here the wrapper is a plain function call. Middleware in Express is a wrapper too: each one gets the request, does its part and calls the next one.
Try it
Predict the output
The discount and the syrup are the same in both orders, only in a different order. What does the program print?
const a = new Discount(new Syrup(new Espresso()));const b = new Syrup(new Discount(new Espresso()));console.log(`€${a.cost().toFixed(2)}`);console.log(`€${b.cost().toFixed(2)}`);Try it
Find the bug
Someone rewrites the syrup:
class Syrup { constructor(inner) { this.inner = inner; }
cost() { return 0.6; }
description() { return `${this.inner.description()}, syrup`; }}const drink = new Syrup(new Milk(new Espresso()));console.log(`${drink.description()}: €${drink.cost().toFixed(2)}`);Espresso, milk, syrup: €0.60The description is right, and the sum is €0.60. Which line is the bug on?
Pick the line with the bug.
The syrup returns only its own price and doesn't ask the inner drink. Everything under it (the espresso and the milk) disappears from the sum, without an error. The description is right, because description() does ask the inner drink, and that is exactly why the bug is easy to miss. A decorator has to call the same method on the object it wraps: return this.inner.cost() + 0.6;. Then the sum is €3.
The idea
When you don't need it
Decorator has a cost: a class for every layer, and the order is an object in an object in an object. It isn't worth it when:
- every add-on only adds a price and a name. Then the add-ons are data: a list of "name, price" pairs, and the sum is the price of the base plus the total of the list. A new syrup is a new row in the menu, not a new class;
- there is one layer and it is never removed. Its code can live in the class itself;
- you wrap an object with many methods and change one. Every wrapper has to forward the rest too, or they are missing.
Watch out for deep stacks too: six layers are six lines in a stack trace, and their order is visible only where they are put together.
It is worth it when the layers do different things (a discount, logging, a cache, a retry), are put together differently for different cases, and you don't want to touch what you wrap.
Exercise
Cappuccino with whipped cream
Solve the task in your editor.
Cappuccino with whipped cream. The coffee shop adds a cappuccino for €2.80 and whipped cream for €0.50. Write the classes Cappuccino and WhippedCream and order a cappuccino with whipped cream twice. The old classes are not touched.
Cappuccino, whipped cream, whipped cream: €3.80More tasks
- How long it takes. Write
timed(fn), which measures the time withperformance.now()and prints how many milliseconds the call took. Use it together with the logging. Which layer is on the outside, and why does it matter? - A large cup. A large cup costs 30% more. Add a layer
Large. Put it once around the espresso and once around the whole order with milk and syrup. Which one is fair to the customer? - A counter. A wrapper around the service counts how many orders went through and prints the count at the end. Think about where the counter lives: in the wrapper or in the service.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Interfaces without interface · Exceptions · Arrow functions · Design patterns
- Continues in: The shop: which pattern where · Caching
- Tasks on this topic: Design patterns