The true prototypial nature beneath "JavaScript classes"
calvintwr
Posted on July 26, 2020
I first wrote this stackoverflow answer in 2015. Obviously things has changed quite a bit, but still think there are much misdirections in JavaScript to address.
This article, as its title would suggest, will be contentious. But please, I am not about to say that we shouldn't use class
and new
. But to make that little dent, catch your attention, and hopefully we can all have some discussions over it.
Mainly is to explore, through a simple syntax, that Javascript is inherently classless, and its powerful prototypial nature obscured by class
and new
.
But, on balance, you have much to gain, and nothing to lose using ES6 classes (provided one writes it readably).
The point at the end of the day, please think of readability. The closer a language looks like to a human language, the better.
World without the "new" keyword.
And simpler "prose-like" syntax with Object.create().
First off, and factually, Javascript is a prototypal language, not class-based. The class
keyword is in fact is just prototypial under the hood. Indulge me, and have a look at its true nature expressed in the simple prototypial form below, which you may come to see that is very simple, prose-like, yet powerful. I also will not use the prototype
property, because I also find it rather unnecessary and complicated.
TLDR;
const Person = {
firstName: 'Anonymous',
lastName: 'Anonymous',
type: 'human',
name() { return `${this.firstName} ${this.lastName}`},
greet() {
console.log(`Hi, I am ${this.name()}.`)
}
}
const jack = Object.create(Person) // jack is a person
jack.firstName = 'Jack' // and has a name 'Jack'
jack.greet() // outputs "Hi, I am Jack Anonymous."
This absolves the sometimes convoluted constructor pattern. A new object inherits from the old one, but is able to have its own properties. If we attempt to obtain a member from the new object (#greet()
) which the new object jack
lacks, the old object Person
will supply the member.
You don't need constructors, no new
instantiation (read why you shouldn't use new
), no super
, no self-made __construct
, no prototype
assignments. You simply create Objects and then extend or morph them.
This pattern also offers immutability (partial or full), and getters/setters.
TypeScript Equivalent
The TypeScript equivalent requires declaration of an interface:
interface Person {
firstName: string,
lastName: string,
name: Function,
greet: Function
}
const Person = {
firstName: 'Anonymous',
lastName: 'Anonymous',
name(): string { return `${this.firstName} ${this.lastName}`},
greet(): void {
console.log(`Hi, I am ${this.name()}.`)
}
}
const jack: Person = Object.create(Person)
Creating an descendant/copy of Person
Note: The correct terms are
prototypes
, and theirdescendants/copies
. There are noclasses
, and no need forinstances
.
const Skywalker = Object.create(Person)
Skywalker.lastName = 'Skywalker'
const anakin = Object.create(Skywalker)
anakin.firstName = 'Anakin'
anakin.gender = 'male' // you can attach new properties.
anakin.greet() // 'Hi, my name is Anakin Skywalker.'
Let's look at the prototype chain:
/* Person --> Skywalker --> anakin */
Person.isPrototypeOf(Skywalker) // outputs true
Person.isPrototypeOf(anakin) // outputs true
Skywalker.isPrototypeOf(anakin) // outputs true
If you feel less safe throwing away the constructors in-lieu of direct assignments, fair point. One common way is to attach a #create
method which you are read more about below.
Branching the Person
prototype to Robot
Say when we want to branch and morph:
// create a `Robot` prototype by extending the `Person` prototype
const Robot = Object.create(Person)
Robot.type = 'robot'
Robot.machineGreet = function() { console.log(10101) }
// `Robot` doesn't affect `Person` prototype and its descendants
anakin.machineGreet() // error
And the prototype chain looks like:
/*
Person ----> Skywalker --> anakin
|
|--> Robot
*/
Person.isPrototypeOf(Robot) // outputs true
Robot.isPrototypeOf(Skywalker) // outputs false
...And Mixins -- Because.. is Darth Vader a human or robot?
const darthVader = Object.create(anakin)
// for brevity, skipped property assignments
// you get the point by now.
Object.assign(darthVader, Robot)
// gets both #Person.greet and #Robot.machineGreet
darthVader.greet() // "Hi, my name is Darth Vader..."
darthVader.machineGreet() // 10101
Along with other odd things:
console.log(darthVader.type) // outputs "robot".
Robot.isPrototypeOf(darthVader) // returns false.
Person.isPrototypeOf(darthVader) // returns true.
Which elegantly reflects the "real-life" subjectivity:
"He's more machine now than man, twisted and evil." - Obi-Wan Kenobi
"I know there is good in you." - Luke Skywalker
In TypeScript you would also need to extend the Person
interface:
interface Robot extends Person {
machineGreet: Function
}
Conclusion
I have no qualms with people thinking that class
and new
are good for Javascript because it makes the language familiar and also provides good features. I use those myself. The issue I have is with people extending on the aforementioned basis, to conclude that class
and new
is just a semantics issue. It just isn't.
It also gives rise to tendencies to write the simple language of Javascript into classical styles that can be convoluted. Instead, perhaps we should embrace:
-
class
andnew
are great syntactic sugar to make the language easier to understand for programmers with class languages background, and perhaps allows a structure for translating other other languages to Javascript. - But under the hood, Javascript is prototypial.
- And after we have gotten our head around Javascript, to explore it's prototypial and more powerful nature.
Perhaps in parallel, it should allow for a proto
and create
keyword that works the same with all the ES6 classes good stuff to absolve the misdirection.
Finally, whichever it is, I hoped to express through this article that the simple and prose-like syntax has been there all along, and it had all the features we needed. But it never caught on. ES6 classes are in general a great addition, less my qualm with it being "misleading". Other than that, whatever syntax you wish to use, please consider readability.
Further reading
Commonly attached #create
method
Using the Skywalker
example, suppose you want to provide the convenience that constructors brings without the complication:
Skywalker.create = function(firstName, gender) {
let skywalker = Object.create(Skywalker)
Object.assign(skywalker, {
firstName,
gender,
lastName: 'Skywalker'
})
return skywalker
}
const anakin = Skywalker.create('Anakin', 'male')
On #Object.defineProperty
For free getters and setters, or extra configuration, you can use Object.create()'s second argument a.k.a propertiesObject. It is also available in #Object.defineProperty, and #Object.defineProperties.
To illustrate its usefulness, suppose we want all Robot
to be strictly made of metal (via writable: false
), and standardise powerConsumption
values (via getters and setters).
const Robot = Object.create(Person, {
// define your property attributes
madeOf: {
value: "metal",
writable: false,
configurable: false,
enumerable: true
},
// getters and setters
powerConsumption: {
get() { return this._powerConsumption },
set(value) {
if (value.indexOf('MWh')) {
this._powerConsumption = value.replace('M', ',000k')
return
}
this._powerConsumption = value
throw Error('Power consumption format not recognised.')
}
}
})
const newRobot = Object.create(Robot)
newRobot.powerConsumption = '5MWh'
console.log(newRobot.powerConsumption) // outputs 5,000kWh
And all prototypes of Robot
cannot be madeOf
something else:
const polymerRobot = Object.create(Robot)
polymerRobot.madeOf = 'polymer'
console.log(polymerRobot.madeOf) // outputs 'metal'
Posted on July 26, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024
November 26, 2024