We never needed classes nor private properties, we have closures β
Letβs create a dummy Chatter using a class and a closure to spot the differences.
Using a class β
class Chatter {
constructor(firstName, lastName) {
this.#firstName = firstName
this.#lastName = lastName
}
get #name() {
return `${this.#firstName} ${this.#lastName}`.trim()
}
wave(folk) {
this.#sendMessage(`π ${recipient}`)
}
#sendMessage(message) {
// fake implementation being fake
console.log(`${this.name}> ${message}`)
}
}19 lines and hidden bugs because of this loose ends, picture this:
const folks = ['Mary', 'Jane', 'Jamie', 'Jack']
const chatter = new Chatter('John', 'Doe')
folks.forEach(chatter.wave)Here we need to bind chatter.wave to the chatter instance to make this line work. This is a fundamentally broken part of JavaScript, to be honest.
Using a closure β
function createChatter(firstName, lastName) {
const getName = () => `${firstName} ${lastName}`.trim()
const sendMessage = (message) => {
// fake implementation being fake
console.log(`${getName()}> ${message}`)
}
return {
wave(folk) {
sendMessage(`π ${recipient}`)
},
}
}13 lines and no hidden bugs, this code will work as expected:
const folks = ['Mary', 'Jane', 'Jamie', 'Jack']
const chatter = createChatter('John', 'Doe')
folks.forEach(chatter.wave)Other goodies β
1. A clean public programmatic interface β
When we use a closure, it becomes immediately apparent that we only expose a single method wave.
When using a class, we need to browse the whole class before knowing what the public interface is.
2. A clean declaration order of private properties and methods β
Take this example:
class Chatter {
#firstName = 'John'
#sendMessage() { β¦ }
wave() { β¦ }
#lastName = 'Doe'
}In a class, I can mix up everything, harming predictability of where to find stuff.
In a closure this is not possible, every private property must be declared at the top, no choice.
As for private methods, we have 2 choices: at the top like private properties, or at the bottom using function hoisting. I favor the latter.
function createChatter(firstName, lastName) {
// private property
const name = `${firstName} ${lastName}`
return {
wave(β¦) { β¦ }
}
// private method
function sendMessage(β¦) {}
}That way, the order is predictable: every private property is at the top, every private methods are at the bottom.
3. TypeScript: no typing redundancy β
When implementing an interface using a class, we need to re-type methods π.
interface Chatter {
wave(folk: string): void
}
class SomeChatter implements Chatter {
// double typing, duh. And this is required by the TypeScript compiler.
wave(folk: string): void {}
}
// vs Closure
function createChatter(): Chatter {
return {
// properly typed, no double typing required.
wave(folk) { β¦ }
}
}4. No this to mess around β
The this keyword and function binding is probably one of the most clunky feature of JavaScript, very error-prone even for experimented developers.
Even knowing about, I still got tricked here and there when using destructuring for instance:
const chatter = new Chatter('John', 'Doe')
const { wave } = chatter
wave('Jack') // broken, `this` is not boundCons β
So we know what we gain, but surely we must lose some things, right?
Well yes, sort of.
Using hoisted functions for both private and public interface β
function createChatter() {
return {
sendMessage, // public
wave: (folk) => sendMessage(`π ${folk}`),
}
function sendMessage() {β¦}
}When the object grows in size, it becomes hard to know which hoisted function is private and which is public.
But in my opinion, we should avoid using public methods in other public methods, it is a smell of harmful DRY (Donβt Repeat Yourself) because by doing so, we introduce a dependency β which is a first knot for spaghetti code.
Plus, this pattern is feasible using both closures and classes anyway.
Therefore, my recommendation: hoisted functions must only be private.