Composite
In short: Composite gives a single item and a group the same shape, so a tree of products and boxes is handled with one call, however many levels deep it goes.
Problem
A box in a box in a box
The shop sells gift boxes. The holiday box holds tea, honey and a smaller box with candy and chocolate. Every box also has a price for its packaging. The order has a candle and the holiday box. The data looks like this:
class Product { constructor(name, price) { this.name = name; this.price = price; }}
class Box { constructor(name, packaging, items) { this.name = name; this.packaging = packaging; this.items = items; }}The total is computed by checking what each item is: a product or a box. For a box there is a second loop:
function total(order) { let sum = 0; for (const item of order) { if (item instanceof Product) { sum += item.price; } else if (item instanceof Box) { sum += item.packaging; for (const inner of item.items) { if (inner instanceof Product) { sum += inner.price; } } } } return sum;}const order = [ new Product("Candle", 5), new Box("Holiday box", 2, [ new Product("Tea", 4.5), new Product("Honey", 6), new Box("Small box", 1, [ new Product("Candy", 3.5), new Product("Chocolate", 2.5)])])];console.log(`Total: €${total(order).toFixed(2)}`);Total: €17.50The order costs €24.50, and the program says €17.50. The small box is on the third level. The second loop sees it, but it isn't a product, and it disappears from the total together with the candy and the chocolate. There is no error.
A third nested loop fixes this order, but not a box on the fourth level. And the same chain of checks is in counting the products and in the weight for the courier too.
The idea
Composite: the part and the whole with one shape
The product and the box are asked the same thing: how much are you? The product answers with its price. The box answers with its packaging plus the answers of everything inside.
So both have the same methodtotal(). The box keeps a list of items and doesn't ask what they are: it calls total() on each one. No interface is needed, as long as every item has this method.This is the Composite pattern. It has three roles:
- the common shape: the method
total(); - the leaf: the product, which computes on its own;
- the container: the box, which keeps children with the same shape and asks each of them.
A box in the box is just a child that also asks its children. This way the calls go down to the products, however many levels there are: this is recursion through objects. The code that asks for the total doesn't know whether it holds a product, a box or a whole tree.
A folder with files has the same shape: the size of a file is its own, and the size of a folder is the total of everything in it, including the folders inside.
See it
The total goes down to the bottom and comes back
The product and the box. The box takes any number of items (...items):
class Product { constructor(name, price) { this.name = name; this.price = price; }
total() { return this.price; }}
class Box { constructor(name, packaging, ...items) { this.name = name; this.packaging = packaging; this.items = items; }
total() { let sum = this.packaging; for (const item of this.items) { sum += item.total(); } return sum; }}The tree is written as nested constructors, one item per line:
const order = new Box("Order", 0, new Product("Candle", 5), new Box("Holiday box", 2, new Product("Tea", 4.5), new Product("Honey", 6), new Box("Small box", 1, new Product("Candy", 3.5), new Product("Chocolate", 2.5))));console.log(`Total: €${order.total().toFixed(2)}`);Total: €24.50const order = new Box("Order", 0, new Product("Candle", 5), new Box("Holiday box", 2, new Product("Tea", 4.5), new Product("Honey", 6), new Box("Small box", 1, new Product("Candy", 3.5), new Product("Chocolate", 2.5))));console.log(`Total: €${order.total().toFixed(2)}`);Step by step, in words
- The tree is put together: every item is on its own line, and every box holds its children.
- total() asks the root. The order doesn't know what is inside: it starts from its packaging and calls total() on every child.
- Candle is a product: it answers with its price, 5.00, and the answer goes back up.
- Holiday box is a box: it does exactly the same, packaging first, then one child at a time.
- Small box is on the third level. Nothing disappears here: it asks its own children.
- Small box: 1.00 + 3.50 + 2.50 = 7.00. Its total goes up to its parent.
- Holiday box: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
- Order: 0.00 + 5.00 + 19.50 = 24.50. That is what the program prints.
There is no type check anywhere and no number of levels anywhere. The box does the same thing on every level: the packaging plus the total of the children.
See it
A new kind of item without touching the box
The shop starts selling coffee beans at €24 per kilo. Its price is the price per kilo times the kilos. This is a new class with the same shape:
class WeighedProduct { constructor(name, perKg, kg) { this.name = name; this.perKg = perKg; this.kg = kg; }
total() { return this.perKg * this.kg; }}const coffee = new Box("Coffee box", 1, new WeighedProduct("Coffee beans", 24, 0.25), new Product("Mug", 8));console.log(`Coffee box: €${coffee.total().toFixed(2)}`);Coffee box: €15.00Not a single line of Box changed. The box calls total() on the coffee, as on any other item.
Here the box takes anything. Text in place of a product goes in without an error, and the program stops only at the total:
const box = new Box("Box", 1, "Tea");console.log(box.total());TypeError: item.total is not a functionThat is still better than the problem, where such an item was silently skipped: here the error shows up as soon as someone asks for the total.
More precisely, if you are curious
A new kind of item is easy: one class. A new operation costs more: if besides the total you also need a weight for the courier, the method weight() is added to every class. Composite is strong where the kinds of items grow and the operations are few.
Try it
Predict the output
The box holds an empty box and a candle. What does the program print?
const box = new Box("Box", 1, new Box("Empty", 0.5), new Product("Candle", 5));console.log(`Box: €${box.total().toFixed(2)}`);Try it
Find the bug
Someone rewrites the box to make it "safer":
class Box { constructor(name, packaging, ...items) { this.name = name; this.packaging = packaging; this.items = items; }
total() { let sum = this.packaging; for (const item of this.items) { if (item instanceof Product) { sum += item.total(); } } return sum; }}const gift = new Box("Holiday box", 2, new Product("Tea", 4.5), new Product("Honey", 6), new Box("Small box", 1, new Product("Candy", 3.5), new Product("Chocolate", 2.5)));console.log(`Holiday box: €${gift.total().toFixed(2)}`);Holiday box: €12.50The holiday box costs €19.50, and the program says €12.50. Which line is the bug on?
Pick the line with the bug.
The check lets only products through. The small box inside isn't a product, so it, the candy and the chocolate (€7) disappear from the total. This is the same bug as in the problem, only hidden inside the box. The container in Composite doesn't ask what the child is: it calls total() on every one. Remove the check, and the total is €19.50.
The idea
When you don't need it
Composite has a cost: a common shape and recursion that you have to read to understand one total. It isn't worth it when:
- the things are in a flat list. A cart without boxes is a list, and the total is one loop;
- there are exactly two levels and it stays that way: an order with lines. An order line doesn't contain an order;
- the operations become many, and there are two kinds of items. Every new operation is written in every class. Then it is sometimes clearer for one function to walk the tree with a check of the kind, but in one place.
It is worth it when parts and wholes are nested in each other on however many levels come: boxes, folders, categories in a catalog, menus with submenus, elements on a page.
Exercise
The receipt
Solve the task in your editor.
The receipt as a tree. Add a method print(indent = "") to the product and to the box. The product prints its name and its price. The box prints its name and its total, and then calls print on every child with two more spaces. Print the order from the card "The total goes down to the bottom and comes back":
Order: €24.50 Candle: €5.00 Holiday box: €19.50 Tea: €4.50 Honey: €6.00 Small box: €7.00 Candy: €3.50 Chocolate: €2.50More tasks
- How many products. Add
count(): a product is 1, and a box is the total of its children. The packaging isn't a product. The order has 5. - Folders and files. Make a file with a name and a size in KB, and a folder with files and folders. Print the size of every folder. What does it have in common with the boxes?
- The most expensive product. Find the price of the most expensive product in the whole tree. Think about what an empty box returns.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Arrays: push, splice and sort · Data, switch and destructuring · Design patterns
- Continues in: Command
- Tasks on this topic: Design patterns