Q1What does instanceof Document return for minutes, which had pieces copied into it with Object.assign?
Composition vs. Inheritance — Object.assign and Mixins
Split features into pieces and combine them in one object. Covers Object.assign, functions that return pieces, mixin functions, and when to use inheritance instead.
Your company's document system needs PDF export for meeting minutes, approval for contracts, and both for quotes. A class can name only one parent after extends, so you end up writing a class for every combination of features.
This article covers composition, which splits features into separate pieces and adds them to objects, and mixin functions, which stack features onto a class.
Adding Only the Pieces Each Document Needs — Object.assign
Suppose you've built an ApprovableDocument class that supports approval, and then, since quotes also need PDF export, an ExportableDocument class on top of it. Meeting minutes only need PDF export, so they'd either inherit approval they never use, or extend Document separately and copy the PDF code over.
With composition (building an object from separate groups of methods, one group per feature), you pass the pieces to Object.assign(target, piece1, piece2). It copies the properties of every argument after the first onto the first one, and returns that first object.
class Document {
constructor(title) { this.title = title; }
}
// Make one group of methods (a piece) per feature
const approvable = {
approve(name) { return `${this.title}: approved by ${name}`; },
};
const exportable = {
toPdfName() { return `${this.title}.pdf`; },
};
// Copy in only the pieces each document needs
const minutes = Object.assign(new Document("Weekly meeting minutes"), exportable);
const estimate = Object.assign(new Document("Repair quote"), approvable, exportable);
console.log(Object.keys(estimate).join(", ")); // title, approve, toPdfName
console.log(estimate.approve("Alice")); // Repair quote: approved by Alice
console.log(minutes.toPdfName()); // Weekly meeting minutes.pdf
console.log(typeof minutes.approve); // undefined (the minutes have no approval)
In estimate.approve("Alice"), estimate is left of the dot, so this inside approve is estimate, and this.title is the quote's title. The target is still an instance of new Document, so instanceof Document is true as well.
Giving Each Document Its Own Comments — Functions That Return Pieces
Now suppose you also want a comment list, so you make { comments: [], addComment(text) { ... } } a piece and copy it into both the minutes and the quote with Object.assign. Only a reference to the array is copied, so both documents' comments point to the same array, and a comment added to the minutes also shows up in the quote's list.
If you rewrite the comment piece as commentable, a function that creates and returns a new piece each time you call it, the [] for comments is a different array on every call. Make approvable a function too and write { ...approvable(), ...commentable() }: spread syntax copies each piece's keys and values into a new object.
// A function that returns a piece: every call creates a new object and array
const commentable = () => ({
comments: [],
addComment(text) { this.comments.push(text); },
});
const approvable = () => ({
approvedBy: "pending",
approve(name) { this.approvedBy = name; },
});
// Spread the pieces to create a new document
const minutes = { title: "Weekly meeting minutes", ...commentable() };
const estimate = { title: "Repair quote", ...approvable(), ...commentable() };
minutes.addComment("Next: the 10th");
estimate.approve("Alice");
console.log(minutes.comments.length, estimate.comments.length); // 1 0 (separate arrays)
console.log(estimate.approvedBy); // Alice
console.log(Object.keys(estimate).join(", ")); // title, approvedBy, approve, comments, addComment
The { } is what creates the new object; spread syntax just copies the keys of each piece's return value into it. Unlike Object.assign, you don't prepare a target first, so what you get is a plain object, not an instance of a class.
With Duplicate Keys, the Last Piece Wins
With both Object.assign and spread syntax, when two pieces share a key name, the value from the piece passed later wins. If approvable() and commentable() both had a describe, only the later one would remain, without any error, so keep method names unique across pieces.
Stacking Features in a Class Definition — Mixin Functions
Elsewhere in the system, quotes are defined as class Estimate and created with new Estimate(...) on each screen. If you add approval and comments afterward with Object.assign, you have to copy them in after every new, and any quote you miss throws a TypeError when approve is called.
A mixin function (a function that takes a class and returns a class that extends it with extra features) is written as (Base) => class extends Base { ... }. class extends Base { ... } is a class expression with no name. You can write a call to this function after extends, and the class it returns becomes the parent.
class Document {
constructor(title) { this.title = title; }
describe() { return this.title; }
}
// Takes a class and returns a class that extends it with extra features
const Approvable = (Base) => class extends Base {
approve(name) { this.approvedBy = name; }
describe() { return `${super.describe()} (${this.approvedBy ?? "pending"})`; }
};
const Commentable = (Base) => class extends Base {
comments = []; // A new array for each instance
addComment(text) { this.comments.push(text); }
};
// Both are stacked in the class definition, so there's nothing to copy in after each new
class Estimate extends Commentable(Approvable(Document)) {}
const estimate = new Estimate("Repair quote");
estimate.approve("Alice");
console.log(estimate.describe()); // Repair quote (Alice)
In Approvable, super.describe() calls the describe of Document, the Base it received. Even if you swap the stacking order to Approvable(Commentable(Document)), you still get both approval and comments.
Extending a Mixin Without Calling It Throws a TypeError
extends Approvable makes the arrow function itself the parent. An arrow function can't be a class's parent, so you get TypeError: Class extends value ... is not a constructor or null. Write Approvable(Document) as the parent instead.
Comparing the Two Approaches — Mixins vs. extends
As you write a mixin function for each feature, the parentheses start to nest, as in class Estimate extends Commentable(Approvable(Document)) {}. The more you stack, the harder it gets to tell from that one declaration line what Estimate is a kind of and which features were added on top.
Compare it with an inheritance version, where CommentableDocument (comments) extends ApprovableDocument (approval). With either approach, instanceof Document is true and you can use super. The difference is whether you can pick the features for each class.
class Document { constructor(title) { this.title = title; } }
const Commentable = (Base) => class extends Base { // The same mixin as in the previous section
comments = []; addComment(text) { this.comments.push(text); }
};
// Inheritance: build the comment class on top of the approval class
class ApprovableDocument extends Document {
approve(name) { this.approvedBy = name; }
}
class CommentableDocument extends ApprovableDocument {
comments = [];
addComment(text) { this.comments.push(text); }
}
const oldMinutes = new CommentableDocument("Weekly meeting minutes");
console.log(typeof oldMinutes.approve); // function (it inherits approval it never uses)
// Stacking with a mixin
class Minutes extends Commentable(Document) {}
const minutes = new Minutes("Weekly meeting minutes");
console.log(typeof minutes.approve); // undefined (only the comment features are added)
console.log(minutes instanceof Document, oldMinutes instanceof Document); // true true
Put features that some kinds of objects need and others don't, like approval and comments, into pieces, and use extends for relationships that fit a single parent-child line, such as "a quote is a kind of document." The table below lists the four approaches used in this article.
| Approach | How features are added | Good for |
|---|---|---|
| extends Parent | Inherits everything from the parent | An "is a kind of" relation |
| Object.assign(target, piece) | Copies into an existing object | Extending one object you got |
| { ...piece() } | Copies into a new object | Building without a class |
| extends Mixin(Parent) | Stacks chosen features | Adding in a class definition |
Knowledge Check
Answer each question one by one.
Q2If you copy one piece's comments: [] into two documents with Object.assign and push to one of them, what happens?
Q3In Commentable(Approvable(Document)), which class directly extends Document?