Object-Oriented Patterns in JavaScript: Classes, Factories, and the Middle Ground
JavaScript supports several distinct approaches to Object-Oriented Programming, each with its own conventions for defining blueprints, creating instances, and managing inheritance. The four principal flavors are Constructor functions, Class syntax, Objects Linking to Other Objects (OLOO), and Factory functions. Before weighing their trade-offs, it helps to establish what OOP actually delivers here: blueprints that produce instances with unique properties, a mechanism for deriving new blueprints (inheritance or subclassing), and the ability to hide internal state behind an object's surface (encapsulation).
All four flavors give you those capabilities, but they differ meaningfully in ergonomics, explicitness, and mental overhead. Here's a look at how each writes and behaves in practice.
The Four OOP Flavors at a Glance
Constructor functions were the original way to build objects before the Class syntax existed. They're regular functions that attach state to a this binding, and you create instances using the new keyword.
function Human (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
The this keyword here stores unique values per call, and instances are created as follows:
const chris = new Human('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
const zell = new Human('Zell', 'Liew')
console.log(zell.firstName) // Zell
console.log(zell.lastName) // Liew
Class syntax is often described as syntactic sugar over constructor functions. Class definitions look more like what developers from C-style languages expect. The body includes a named constructor function that performs the initial setup you'd otherwise put inside the constructor function directly.
class Human {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
}
It still requires new to instantiate, and an explicit constructor is optional if you have no fields to initialize (more on that under subclassing).
const chris = new Human('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
The OLOO pattern — coined by Kyle Simpson — drops the formal definition syntax. Blueprints are plain objects, and you supply an init method to prepare an instance. Though init is conventional, no naming is mandated by the runtime the way constructor is required by the Class grammar.
const Human = {
init (firstName, lastName ) {
this.firstName = firstName
this.lastName = lastName
}
}
You create an instance by calling Object.create(prototype) and invoking the init method yourself.
const chris = Object.create(Human)
chris.init('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
Returning this from init makes these two steps chain into one line.
const Human = {
init () {
// ...
return this
}
}
const chris = Object.create(Human).init('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
Finally, Factory functions avoid new and keyword syntax entirely. Any function that returns a fresh object qualifies — the object can even be an internally generated Class or OLOO instance if you want to mix approaches.
function Human (firstName, lastName) {
return {
firstName,
lastName
}
}
You don't need the new keyword; as with any regular function, you just call it.
const chris = Human('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier
Declaring Members: Instances vs. Prototypes
A method is simply a function assigned as an object property.
const someObject = {
someMethod () { /* ... */ }
}
OOP flavors differ in how easily you can put properties and methods on the instance versus on the prototype. With Constructors, instance properties go inside the Constructor function body:
function Human (firstName, lastName) {
// Declares properties
this.firstName = firstName
this.lastname = lastName
// Declares methods
this.sayHello = function () {
console.log(`Hello, I'm ${firstName}`)
}
}
const chris = new Human('Chris', 'Coyier')
console.log(chris)

But methods are commonly shelved on the prototype to keep each instance's memory footprint small, since all instances can share a function. That requires reaching for the prototype property:
function Human (firstName, lastName) {
this.firstName = firstName
this.lastname = lastName
}
// Declaring method on a prototype
Human.prototype.sayHello = function () {
console.log(`Hello, I'm ${this.firstName}`)
}

Defining multiple prototype methods with this syntax gets clunky.
// Declaring methods on a prototype
Human.prototype.method1 = function () { /*...*/ }
Human.prototype.method2 = function () { /*...*/ }
Human.prototype.method3 = function () { /*...*/ }
A merge like Object.assign reduces some of that verbosity.
Object.assign(Human.prototype, {
method1 () { /*...*/ },
method2 () { /*...*/ },
method3 () { /*...*/ }
})
One catch: Object.assign cannot handle Getter and Setter properties — a merge library such as mix is needed for them.
Classes make the prototype case particularly clean. Constructor-side properties still go inside the constructor:
class Human {
constructor (firstName, lastName) {
this.firstName = firstName
this.lastname = lastName
this.sayHello = function () {
console.log(`Hello, I'm ${firstName}`)
}
}
}

And methods just need a function declaration after constructor in the class body — no separators required:
class Human (firstName, lastName) {
constructor (firstName, lastName) { /* ... */ }
sayHello () {
console.log(`Hello, I'm ${this.firstName}`)
}
}

Multiple methods are just as concise; there's no , between class members.
class Human (firstName, lastName) {
constructor (firstName, lastName) { /* ... */ }
method1 () { /*...*/ }
method2 () { /*...*/ }
method3 () { /*...*/ }
}
OLOO uses object literal syntax, so assigning to the prototype means adding properties right to the blueprint object. Instance-side members need the same assignment-in-init route as with constructors:
const Human = {
init (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
this.sayHello = function () {
console.log(`Hello, I'm ${firstName}`)
}
return this
}
}
const chris = Object.create(Human).init('Chris', 'Coyier')
console.log(chris)

const Human = {
init () { /*...*/ },
sayHello () {
console.log(`Hello, I'm ${this.firstName}`)
}
}

Factory functions only support the instance route, since you put properties (and closures) directly into the returned object.
function Human (firstName, lastName) {
return {
firstName,
lastName,
sayHello () {
console.log(`Hello, I'm ${firstName}`)
}
}
}

Returning a Class or OLOO instance from a factory would technically get you prototype-backed objects, but that inverts the spirit of factories — they exist to return fresh plain objects with closed-over state.
// Do not do this
function createHuman (...args) {
return new Human(...args)
}
Instance Members or Prototype Members?
The perennial question inevitably surfaces: place members on the prototype for maximum reuse? In practice the trade-off is not worth agonizing over. Instance-level members consume slightly more memory, which today is rarely a real constraint. The deciding factor is what your syntax encourages. Classes and OLOO read naturally with prototype-wide methods — you'd be swimming against the current to favor instances. Factories give you no prototype option at all, but you lose little in practical terms since composing state in closures tends to align with object composition anyway.
Focusing on Classes and Factories
Among the four approaches, two stand out in routine use. Class syntax lacks constructors' needless ceremony around prototype extension, while factory functions make private state equally natural through closures — something classes only replicate with awkward brand checks and awkward workarounds. OLOO, attractive as it is on paper, tends to lose out because the two-step Object.create(...) + init() is so easy to forget — init won't run automatically, unlike a Class's constructor. That manual ceremony is a recurring source of latent bugs.
Inheritance with Classes vs Factories
Compared accurately, inheritance in JavaScript has two separate senses. One is simply "don't instantiate without getting your parent properties" — that's every object that exists. The second sense — actually deriving a child *blueprint* that extends a parent's interface — is subclassing, and that requires explicit structuring. The word "inheritance" gets used for both, and that conflation clouds much commentary on the topic.
Subclassing with Class syntax requires the extends keyword. You also call super() from the child constructor to trigger the parent's initializer.
class Child extends Parent {
// ... Stuff goes here
}
Suppose a Human blueprint:
// Human Class
class Human {
constructor (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
sayHello () {
console.log(`Hello, I'm ${this.firstName}`)
}
}
A Developer subtype that touches extra skills would resolve like so:
class Developer extends Human {
constructor(firstName, lastName) {
super(firstName, lastName)
}
// Add other methods
}
The child can skip defining its own constructor entirely if no extra setup logic is needed — in that case the default derived constructor serves nicely.
class Developer extends Human {
// Add other methods
}
A subtype method is added just like a normal method:
class Developer extends Human {
code (thing) {
console.log(`${this.firstName} coded ${thing}`)
}
}
Examining state on fresh instance developer via instance access shows their unique fields.
const chris = new Developer('Chris', 'Coyier')
console.log(chris)

Subclassing with factory functions requires four steps: create a new factory, create an instance of the parent, clone that instance, and extend the copy with the child's additions.
Starting from the same Human factory:
function Human (firstName, lastName) {
return {
firstName,
lastName,
sayHello () {
console.log(`Hello, I'm ${firstName}`)
}
}
}
The Developer factory initiates with the same strategy:
function Developer (firstName, lastName) {
const human = Human(firstName, lastName)
return Object.assign({}, human, {
// Properties and methods go here
})
}
function Developer (firstName, lastName) {
const human = Human(firstName, lastName)
return Object.assign({}, human, {
code (thing) {
console.log(`${this.firstName} coded ${thing}`)
}
})
}
const chris = Developer('Chris', 'Coyier')
console.log(chris)

An object spread or Object.assign will flatten an instance's own fields; only getters and setters again require special merging like mix.
Overriding a Parent Method
Whatever pattern you select, the mechanics of method override in a subclass are essentially three steps — make a subroutine under the same name, assess whether the original still makes claims on the object unless called, and customize behavior at the point you need. Both approaches show the pragmatically identical product of these steps:
class Developer extends Human {
sayHello () {
// Calls the parent method
super.sayHello()
// Additional stuff to run
console.log(`I'm a developer.`)
}
}
const chris = new Developer('Chris', 'Coyier')
chris.sayHello()

function Developer (firstName, lastName) {
const human = Human(firstName, lastName)
return Object.assign({}, human, {
sayHello () {
// Calls the parent method
human.sayHello()
// Additional stuff to run
console.log(`I'm a developer.`)
}
})
}
const chris = new Developer('Chris', 'Coyier')
chris.sayHello()

Composition Over Inheritance
The words of the Gang of Four and practitioners like Eric Elliott ring comfortably well worn: “favor object composition over class inheritance” because it treats parent classes as bundles of shared behavior rather than a rigid structure that collapses under multiple-concern pressure.
Composition merges behaviors as independent ingredients rather than entangling a family tree.
const one = { one: 'one' }
const two = { two: 'two' }
const combined = Object.assign({}, one, two)
Reasoning from the same codebase — say, a set of Designer and Developer roles extending Human — it gets awkward to graft a third one: what if someone is both a designer and a developer? In a class-only world, multiple inheritance triggers the Diamond Problem, where order of resolution makes output ambiguous. JavaScript permits Object.assign to pick precedence arbitrarily, cascading that ambiguity, but multilingual grammars shake their head at letting you extend two classes even then.
class Human {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
sayHello () {
console.log(`Hello, I'm ${this.firstName}`)
}
}
class Designer extends Human {
design (thing) {
console.log(`${this.firstName} designed ${thing}`)
}
}
class Developer extends Designer {
code (thing) {
console.log(`${this.firstName} coded ${thing}`)
}
}

// Doesn't work
class DesignerDeveloper extends Developer, Designer {
// ...
}
Composition dissolves the fence by redefining DesignerDeveloper with pieces gathered from directly adding features like design and code onto the base — a process that mirrors how both developers and designers look at their professional toolset. Note that each instance bears explicit method-defining assignments or closure-carried outputs.
const skills = {
code (thing) { /* ... */ },
design (thing) { /* ... */ },
sayHello () { /* ... */ }
}
class DesignerDeveloper {
constructor (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
Object.assign(this, {
code: skills.code,
design: skills.design,
sayHello: skills.sayHello
})
}
}
const chris = new DesignerDeveloper('Chris', 'Coyier')
console.log(chris)

class Designer {
constructor (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
Object.assign(this, {
design: skills.design,
sayHello: skills.sayHello
})
}
}
class Developer {
constructor (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
Object.assign(this, {
code: skills.code,
sayHello: skills.sayHello
})
}
}
Should the need arise, those procedures split between instance attributes and prototype routes with classes, but direct instance property assignment shows the least messy picture for the hybrid factory: both behaviors reduce same effort to `...` spread inside new factory bodies, or Object.assign-inspired compositional extension patterns.
function DesignerDeveloper (firstName, lastName) {
return {
firstName,
lastName,
code: skills.code,
design: skills.design,
sayHello: skills.sayHello
}
}

In neither world are inheritance and composition irreconcilable. A DeveloperDesigner remains a Human while gaining separate role skills:
class Human {
constructor (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
sayHello () {
console.log(`Hello, I'm ${this.firstName}`)
}
}
class DesignerDeveloper extends Human {}
Object.assign(DesignerDeveloper.prototype, {
code: skills.code,
design: skills.design
})

Factories port the pattern through object spreads before returning the combined signature.
function Human (firstName, lastName) {
return {
firstName,
lastName,
sayHello () {
console.log(`Hello, I'm ${this.firstName}`)
}
}
}
function DesignerDeveloper (firstName, lastName) {
const human = Human(firstName, lastName)
return Object.assign({}, human, {
code: skills.code,
design: skills.design
}
}

Subclassing retains genuine practical utility. The browser DOM leans on it implicitly: a click event carries its lineage through MouseEvent, UIEvent, then Event, letting down-stream logic test types over ancestry. Likewise, element types inherit Node's traversal and content methods out of the box — patterns naturally folded into JavaScript’s familiar runtime.
Encapsulation, Tailored to the Pick
After that structural review, a comparison between classes and factory functions hinges equally on visibility. Under the Class scheme, properties declared inside the constructor are public, and naming conventions historically performed crude privacy signals. Factory functions import closer to the closure idiom: local variables inside a factory stay in that invocation, guarding state even against method misuse or runtime trojans shipping near them — equivalent behavior at lower syntactical priming distance from day one.
That being said, classes still instantiate crisply; method declarations bundle coherently; familiar extends stays concise. Factory names read simply and naturally compile their state into stored objects. Current free-form comparisons are enough to pick precisely which feels like home — there isn't one right number of tokens to write. The most immediate visible differentiator is direct syntax around member implementation, then how feature blocks interleave with this timing style when objects collide.
Encapsulation: Keeping State Where It Belongs
Encapsulation means hiding data so it can’t leak into the surrounding scope. The simplest level is block scope: variables declared inside a block are invisible outside it, while code inside can still read outer variables. Note that var ignores block boundaries — use let or const to get real scoping.
Functions give the same protection for every kind of variable. Anything declared inside a function stays local, and the function can return values to the outside world. Closures take this further: wrap a function in another function and the inner one can reach the outer function’s variables even after the outer call returns.
Objects raise the practical question: which properties should be public, and which should stay private so callers can’t corrupt your implementation? Consider a Car blueprint that starts each instance with 50 liters of fuel. If fuel is a plain public property, anyone can overwrite it — say, with a value exceeding the tank’s 100-liter capacity. Two patterns prevent that.
Private by convention
Prefix a property with an underscore to signal it’s off-limits, and expose getFuel and setFuel methods for controlled access. That’s only a social contract, though: _fuel is still readable and writable from outside. For real privacy, you need language-level support.
True private members
Classes support genuine private fields with the # prefix. You must declare the private field before using it in the constructor, but declaring #fuel with an initializer is enough when the value is fixed. Attempts to read #fuel outside the class throw an error; you access it through explicit methods or, more readably, getters and setters.
Factory functions make privacy automatic. A variable declared inside a factory is function-scoped, hence encapsulated by default. You simply write ordinary variables and then attach getter and setter functions to return or modify them — no prefixes required.
Verdict: factory functions give cleaner encapsulation because they rely on scoping rules JavaScript already has. Class-based privacy works but adds the # syntax and extra declarations. The remaining comparison point — how this behaves — determines which style you’ll prefer day to day.
The this variable in Classes vs. Factory Functions
The value of this depends on its call context, which makes it a frequent source of confusion. In a class, this inside the constructor refers to the new instance, which is why you can attach properties and methods there. Constructor functions behave the same way when invoked with new.
The trap appears when you use this inside a plain factory function. Because there is no new call, this falls back to the global object (or undefined under modules and bundlers). The correct way to use this in a factory is inside a method — a property that holds a function invoked as instance.method().
You don’t have to use this in factories at all. Since the factory’s variables are in lexical scope, methods can reference firstName directly instead of human.firstName or this.firstName. The shorter form is also clearer: when you read the code, the identifier resolves to the obvious enclosing variable, not an object lookup.
Detailed comparison: subclassing
To see how this plays out, model a Human with firstName, lastName, and sayHello, then derive a Developer that adds a code method and overrides sayHello to append I'm a Developer.
With classes, Developer extends Human and the overridden method calls super.sayHello() first. Factory functions call the Human factory to build an object, then copy or extend it; to reach the parent’s method you call it on the human instance you created inside.
The factory version can omit this entirely. Since firstName sits in the lexical scope of both factory functions, the inherited methods keep working without any property assignment. The class version has no such option: this is the only way to reach instance data.
Verdict: classes force this everywhere; factory functions don’t. That preference rests on two drawbacks of this: its context can change at runtime, and expressions like this.#privateVariable read worse than a bare privateVariable.
Event listeners
Most OOP examples stop short of user interaction, but frontend code lives on events — and event listeners are exactly where this shifts under you. In a click handler, this points to the DOM element, not your instance. That affects classes and factories differently.
Build a simple counter to compare. The markup holds a <span> for the count and a <button> to increment it. Both implementations receive the container element, find those two children, and keep the count private.
Counter with Classes
Store the count in a private #count field initialized from the span’s text. Add an increaseCount method that bumps the value and calls updateCount to write the new number back to the DOM.
Passing increaseCount straight to the event listener fails: inside the callback this is the button, so this.#count is undefined. Two fixes exist:
bind(this)returns a new function locked to the instance — correct but verbose.- Defining
increaseCountas an arrow-function class field captures the instance’sthisat creation time. Then the listener can receive the method reference directly.
Arrow-function fields are the cleaner choice: no wrapper in the listener setup and no call to bind.
Counter with Factory functions
In the factory, look up the span and button into plain local variables, read the initial count, and write increaseCount and updateCount as methods on the returned object. Because those locals — count, buttonElement, and the methods themselves — are function-scoped, everything stays private without extra syntax.
For the listener, you can pass counter.increaseCount directly. There is no this inside that method to lose; it closes over the factory’s locals. Use counter.updateCount rather than this.updateCount for readability.
The gotcha: If you do write factory methods that rely on this, event listeners break exactly as they do with classes — the listener rebinds this to the element. And you can’t rescue it the way you would in a class: factory methods defined as arrow functions are created in simple function context, so this won’t point at the instance either. The straightforward rule for factories: omit this completely.
Which way to go
Classes and factory functions both handle real components. The tally from the earlier sections holds up:
- Subclassing reads more naturally with classes (
extends/super), while composition falls out of factory functions. - Encapsulation is native to factory functions; classes need the
#prefix. thisis mandatory in classes and optional — better avoided — in factories.- Event listeners work in both, provided you use arrow-function fields in classes and skip
thisin factories.



