Hey,
I've been watching this proposal for a while now fearing that it would ignore the Liskov Substitution Principle, cause silly bugs in code that one would otherwise presume to behave as their ES5 counterpart and not align with the intent of ES6 non-enumerated class members. I'll go over the arguments in detail, but they all ask why the implementation of object spread is in own-properties terms — Object.assign
— and propose that's not a good choice.
Liskov Substitution Principle
LSP, I'd argue as one fundamental pillar of object orientation, states that any object must be replaceable by its subtype. In a prototypical language such as JavaScript, an object inheriting from another is a subtype of the other. In plain JavaScript, that holds pretty much in all cases — it surely holds for regular property access and it'd be silly if it didn't. That is, the following two name variables are equivalent.
var a = {name: "John"}
var b = Object.create(a); b.age = 42
var nameA = a.name
var nameB = b.name
However, if we interject object rest spread there, let's say in some intermediate function, we'll break LSP and introduce a hard to spot bug for no benefit:
function printName(obj) { console.log(obj.name) }
function printAgeAndName(obj) {
var {age, ...rest} = obj
console.log(obj.name)
printName(rest)
}
var person = {name: "John"}
var agedPerson = Object.create(person); agedPerson.age = 42
printAgeAndName(agedPerson)
Mind you, printAgeAndName
is doing the right thing with its intent of passing a supertype to printName
, that is, a type without the age
property (regardless of where age
sits on the prototype hierarchy). It is NOT doing the right thing, however, by losing name
entirely. Perhaps previously, without rest properties, printAgeAndName
didn't care about removing age
before passing it on as-is, and that worked, too.
But now what could've been an innocent refactoring, turned out to be API breakage dependent on the implementation of ...rest
. I wouldn't expect the regular Joe to realize that. It's perfectly reasonable to imagine ...rest
being a shorthand for typing all the other properties out, and if you'd done that, you'd have gotten their proper, inherited values. After all, you already know the "type" or structure of the record you're dealing with when you use the ...rest
syntax. It's not a map of random keys and values.
ES6 non-enumerated class members
At some point ES6 class properties were made non-enumerable. Presumably to allow their data properties to be enumerated, skipping methods. That allows for record-like classes to easily written and used like plain objects, enabling lazy computation:
class Url {
constructor(href) { this.href = href }
toString() { return this.href }
}
Object.defineProperty(Url.prototype, "path", {
value: function() { return this.href.split("?", 1)[0] },
configurable: true, writable: true, enumerable: true
})
var url = new Url("foo/bar?baz")
for (var key in url) console.log(key)
The for-in
enumeration will list both href
and path
, but compute path
lazily when accessed (memoization left as an exercise to the reader). Now imagine a situation similar to the printAgeAndName
I brought up above. A function that handled this memoizing class just fine runs it through ...rest
. Or perhaps to just make a copy for mutating. We'll have, again, lost enumerable properties that were implemented on the prototype for efficiency.
Summary
I'd argue the only valid criteria for inclusion of a properties should be its enumerability.
- It would be consistent by adhering to the Liskov Subtitution Principle.
- It would be aligned with the grain of a prototypical language.
- It would be aligned with property access
- It would allow objects or record with behavior (the definition of object orientation in the first place) for lazy computation as displayed in the
Url
example above.
- Have other good design qualities that I can't be bothered to go in to now. 😇