Mert Özen Further With Every Line
Frontend JavaScript Advanced — #03

JavaScript OOP Patterns: Inheritance or Composition? Factory Functions and Mixins

Mert Özen Nov 3, 2026 9 min 6 views
JavaScript OOP Patterns: Inheritance or Composition? Factory Functions and Mixins

We talk about the cost of inheritance, combining behavior with composition, factory functions and mixins, choosing between closures and #private, the SOLID principles that fit JavaScript, and when a class is worth writing.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

We wrote a small function that creates a counter. We try to push the counter's value up to 100 from the outside. What does this code print to the console?

function createCounter() {
  let count = 0;
  return {
    increment() {
      count += 1;
      return count;
    }
  };
}

const counter = createCounter();
counter.increment();
counter.count = 100;

console.log(counter.increment());

Make a guess before you keep reading. Is it 101, 2, or an error? We'll explain the answer when we talk about ways to hide data.

The Appeal and the Cost of Inheritance

In the last two posts we saw how objects are linked and how they're protected. Now we get to the real question: how should you design the objects in a program? Most of us start object-oriented programming with inheritance, and the first design always looks perfectly sensible:

class Animal {
  eat() { return "eating"; }
}
class Bird extends Animal {
  fly() { return "flying"; }
}
class Penguin extends Bird {
  fly() { throw new Error("Penguins can't fly"); }
}

const pingu = new Penguin();
console.log(pingu.eat()); // "eating"
console.log(pingu instanceof Bird); // true
// pingu.fly() → Error: Penguins can't fly

This is where the trouble starts. A penguin is a bird, yet it refuses the most basic behavior of a bird. Any code that expects a Bird can blow up when it's handed a Penguin. Tomorrow you need a bird that swims, the day after a mammal that flies (a bat), and the tree gets messier and messier. Inheritance asks you to answer "what is a subtype of what" at the very start of the design, and real life keeps changing that answer.

Then there's the fragile base class problem: a small change in a parent class can break dozens of subclasses that inherit from it and that you never looked at. Joe Armstrong, the creator of Erlang, summed it up like this: you wanted a banana, but what you got was a gorilla holding the banana and the entire jungle. When you take a behavior through inheritance, you also take everything that behavior depends on.

Composition: Combining Behaviors Piece by Piece

Composition asks a different question. Instead of "what is this object?" it asks "what can this object do?" If inheritance is a family tree, composition is a box of Lego: each piece does one job, and you combine the ones you need.

const canEat = (state) => ({
  eat() { return `${state.name} is eating`; }
});
const canFly = (state) => ({
  fly() { return `${state.name} is flying`; }
});
const canSwim = (state) => ({
  swim() { return `${state.name} is swimming`; }
});

function createPenguin(name) {
  const state = { name };
  return { ...canEat(state), ...canSwim(state) };
}

function createDuck(name) {
  const state = { name };
  return { ...canEat(state), ...canFly(state), ...canSwim(state) };
}

const penguin = createPenguin("Pingu");
const duck = createDuck("Donald");

console.log(penguin.swim()); // "Pingu is swimming"
console.log(duck.fly()); // "Donald is flying"
console.log("fly" in penguin); // false

The penguin no longer has a fly method it has to refuse, because it was never given one. When you want to add a bat, you don't rearrange a tree; you combine canEat and canFly. Each capability is small and understandable on its own. That's the heart of "favor composition over inheritance": not banning inheritance, just not making it the default choice.

Factory Functions: Creating Objects Without new

createPenguin above and createCounter at the top are both factory functions. A factory is just an ordinary function that returns an object. No new, no this, which means the this-loss problems we saw earlier disappear as well. A method doesn't break when you hand it somewhere else as a callback, because it reaches its data through a closure rather than through this:

function createTimer(label) {
  let seconds = 0;
  return {
    tick() {
      seconds += 1;
      console.log(`${label}: ${seconds} s`);
    }
  };
}

const timer = createTimer("Tea");
const tick = timer.tick;
tick(); // "Tea: 1 s"

const another = createTimer("Coffee");
console.log(timer.tick === another.tick); // false

The last line shows the cost. Every factory call creates fresh copies of the methods. The prototype sharing we saw in earlier posts isn't happening here. For a few hundred objects the difference doesn't matter; if you're creating hundreds of thousands at once, classes and prototypes win on memory and speed. A factory has another advantage: depending on the situation it can return different kinds of objects, or hand back an existing one from a cache. A constructor always starts with a new object.

Mixins: Adding Capabilities to Classes

If you're using classes and don't want to be limited to a single chain, you reach for mixins. The simplest form copies an object of methods onto a prototype: Object.assign(User.prototype, serializableMixin). A more powerful approach is a function that takes a class and returns a class that extends it:

const Serializable = (Base) => class extends Base {
  serialize() {
    return JSON.stringify(this);
  }
};

const Timestamped = (Base) => class extends Base {
  constructor(...args) {
    super(...args);
    this.createdAt = new Date().toISOString();
  }
};

class Note {
  constructor(text) {
    this.text = text;
  }
}

class SmartNote extends Serializable(Timestamped(Note)) {}

const note = new SmartNote("Buy milk");
console.log(note.serialize());
// {"text":"Buy milk","createdAt":"..."}
console.log(note instanceof Note); // true

Here the chain of SmartNote passes through the class produced by Serializable, then the class produced by Timestamped, and reaches Note. Each mixin adds one link, and super works as usual. Since createdAt carries the moment you run it, we shortened the value in the comment.

The risk with mixins is name collisions. If two mixins define a method with the same name, the outermost one in the chain wins, and nobody tells you. As the number of mixins grows, the question "where does this method come from?" gets harder too. Use a small number of well-named mixins that don't depend on each other.

Encapsulation: Closure or #private?

Back to the opening question. The output is 2. The count variable lives in the scope of createCounter, and there's no way to reach it from outside. The line counter.count = 100 didn't touch that variable; it created a new and completely unrelated property called count on the object. Thanks to the closure, increment still sees the real count, and it took it from 1 to 2.

In classes, the private fields that arrived in ES2022 provide the same protection. With the # syntax we saw earlier:

class BankAccount {
  #balance = 0;

  deposit(amount) {
    if (amount <= 0) throw new RangeError("Amount must be positive");
    this.#balance += amount;
    return this.#balance;
  }

  static isAccount(value) {
    return #balance in value;
  }
}

const account = new BankAccount();
account.deposit(250);
account.balance = 1_000_000;

console.log(account.deposit(50)); // 300
console.log(BankAccount.isAccount(account)); // true
console.log(BankAccount.isAccount({ balance: 1 })); // false

Writing account.#balance outside the class is a SyntaxError; the code doesn't even start running. A small note: to make debugging easier, Chrome DevTools lets you read private fields in the console. Don't assume something works in real code just because it works in the console. #balance in value, which also came with ES2022, is the reliable way to check whether an object was really created by this class.

How do you choose between the two? A closure needs a separate copy of each function per object, but it's dead simple. #private fields work with prototype methods, so they're memory-friendly. Subclasses can't access private fields either. Names starting with an underscore (_balance) are only a courtesy convention; they hide nothing.

A Few SOLID Principles That Carry Over to JavaScript

The SOLID principles were born in class-heavy languages, but some of them are useful in JavaScript too. Single responsibility: a module or object should have only one reason to change. One object that validates a user, writes to a database and sends an email changes for three different reasons. Open-closed: you should be able to add new behavior by extending existing code instead of modifying it; in JavaScript that usually means a function passed as a parameter, or a strategy object. We already saw Liskov with the penguin: a subtype used in place of its parent type shouldn't break anything.

The most practical one is dependency inversion. Instead of creating what it needs itself, an object should receive it from the outside:

function createNotifier({ send }) {
  return {
    welcome(user) {
      return send(user.email, `Welcome, ${user.name}!`);
    }
  };
}

const consoleNotifier = createNotifier({
  send: (to, text) => console.log(`[${to}] ${text}`)
});

consoleNotifier.welcome({ name: "Alan", email: "alan@example.com" });
// "[alan@example.com] Welcome, Alan!"

createNotifier doesn't know or care how the message gets sent. Today it writes to the console; tomorrow you hand it a send that posts to a server with fetch, and not a single line of welcome changes.

When to Use a Class, When a Plain Function?

There's no hard rule, but there is a useful compass. Use a class if you're creating many objects of the same shape, if you need type checks with instanceof, if you're extending a built-in type like Error, or if the platform asks you for a class, as with the Web Components we'll see later. If you have few objects, want to mix behaviors freely or would rather not deal with this, factories and composition are more comfortable.

And often you need neither. For work that takes some data and returns new data, plain functions are best: calculateTotal(cart), formatPrice(amount). You don't have to make everything a method of some object. Keep data in plain objects and behavior in small functions; build an object when you really need to keep a piece of state together with the rules that protect it.

Common Pitfalls

Using inheritance just to share code. Don't derive one class from another because they have three lines in common; move those three lines into a function. Inheritance establishes an "is a" relationship; sharing code is a side effect of that.

Losing getters when composing with spread. Spread calls the getter and copies its result as a plain value; the live link is broken:

const source = {
  first: "Ada",
  last: "Lovelace",
  get full() { return `${this.first} ${this.last}`; }
};

const copy = { ...source };
copy.first = "Grace";
console.log(copy.full); // "Ada Lovelace"

If you want to keep the getters, use the descriptor tools from the previous post: Object.defineProperties({}, Object.getOwnPropertyDescriptors(source)).

Passing a class method as a callback and losing this. If you write button.addEventListener("click", account.deposit), the method runs, but this is no longer account; the moment it tries to access the private field you get a TypeError. Wrap it in an arrow function or use bind. Finally, turning the patterns themselves into the goal: building a chain of mixins for a three-line job creates more problems than it solves.

A Small Experiment

★☆☆ Predict before running: const a = createCounter(); const b = createCounter(); a.increment(); a.increment(); console.log(a.increment(), b.increment()); Do the two counters share the same count?

★★☆ Add a canWalk to canEat, canFly and canSwim, and use them to write a createBat factory for something that walks and flies but can't swim. Then, to prove that every capability shares the same state object, add an energy field to state and have every action reduce it by one.

★★★ Write an Observable mixin similar to the Serializable mixin: it adds listeners with on(eventName, fn) and calls them with emit(eventName, data). Keep the listener list in a #listeners private field. Add it to SmartNote and emit a "saved" event on every serialize call.

Throughout this post we focused on how objects are built, and our code always ran top to bottom, in order. In the next post we go down to how that order is really decided: the task and microtask queues of the event loop, when the screen gets painted, and why setTimeout sometimes runs later than you expected.