Extending builtin natives. Evil or not?
Couple of days ago, Nick Morgan asked my opinion on extending native objects. The question came up when trying to answer — “why doesn’t underscore.js extend built-ins”? Why doesn't it define all those Array methods — like map
, forEach
, every
— on Array.prototype
. Why does it put them under _
"namespace" — _.each
, _.map
, _.every
, etc. Is it because extending built-in natives is evil? Or is it not? The thread quickly filled with conflicting ideas...
I often see this confusion about extending things in Javascript.
There’s a big difference between extending native built-in objects and extending host objects. I tried to explain what’s wrong with extending host objects in a blog post, a while back. Now, if you look at the list of problems with extending host objects it’s easy to see that most of them don’t really apply to native, built-in objects.
To avoid any confusion, by native, built-in objects I’m talking about objects and methods introduced in ES5 — Array.prototype
extensions (forEach
, map
, reduce
, etc.), Object
extensions (Object.create
, Object.keys
, etc.), Function.prototype.bind
, String.prototype.trim
, JSON.*
, etc. These are the things that are shimmed most often. And the question is — is it OK to extend native, built-in objects with these standardized methods?
Well, let’s quickly go over problems with host objects extension:
Host vs. Native
“Lack of specification” doesn’t apply here, as long as methods that are being shimmed are part of ES5. ES5 is a standard. There’s a publicly available specification. Implementing ES5 methods according to spec is doable (except certain edge-ish cases).
“Host objects have no rules” doesn’t apply either. This is native objects we’re dealing with, and semantics of native objects are very well defined in those same ECMA-262 specifications. What this means in practice is that unless we’re dealing with faulty implementations, adding method
bind
toFunction.prototype
should allow us to add it. There’s no uncertainty aboutFunction.prototype
throwing error on extension, or silently ignoring our command (after all, the spec says: “The initial value of the [[Extensible]] internal property of the Function prototype object is true”). Ditto for other objects.“Chance of collisions” is non-existent as well. Since the methods that are being shimmed are part of a standard, and we’re shimming them according to standard, there’s no chance of collisions of any sort. Either implementation has those methods, or it doesn’t. If it doesn’t, methods are shimmed. That’s it.
“Performance overhead” not only doesn’t exist, but could actually be the opposite of what happens. It’s likely that
[].forEach(...)
will be faster then_.forEach([], ...)
, but even if it isn’t, there should certainly be no performance hit with former version. Contrary to DOM objects that might not have [[Prototype]]’s exposed for public extension, there’s no need to manually extend arrays, objects and strings with these methods. Conceptually, there’s no performance overhead there.“IE DOM is a mess” doesn’t apply. We’re not dealing with DOM. And native objects are extension-friendly in IE, as far as I know.
So what do we have?
Well, it looks like properly extending native objects — unlike host ones — is actually not all that bad. This is of course considering that we're talking about standard objects and methods. Extending native built-ins with custom methods immediately makes "collision" problem apparent. It violates "don't modify objects you don't own" principle, and makes code not future-proof.
Downsides
Are there any downsides?
Well, for once, there are cases when certain scripts mess up native objects/methods in a non-compliant way. Kind of like what Prototype.js does with some of its methods (e.g. Array.prototype.map
or Array.prototype.reverse
; standard-compliance is planned for future releases, as far as I know). If the shim adds standard-compliant methods, and application expects those methods to be non-compliant (but script/library-specific), then there could obviously be problems.
Second, as I mentioned above, while we know that native objects are free for extension, there’s always a risk of running into an oddball environment which doesn’t conform to spec. Keeping methods on a standalone (user-defined) object can avoid such scenarios. Whether this could be considered an issue depends on how paranoid you are.
Finally, you have to be careful when shimming methods that are not universally shimmable. Like Object.create
, which had a very popular non-compliant shim floating around for a while. The method was defined directly on an Object
, but silently failed to do anything useful with second argument — a set of property descriptors. Adding cross-browser support for property descriptors is a rather complicated endeavor, which is why defining such methods on a standalone object could save you some trouble (you could just implement a subset of Object.create
functionality and call it a day).
Don’t forget that writing proper, compliant shims is hard. When in doubt, use standalone object. When the method you’re shimming is part of the unfinished spec, use standalone object. Only when you’re certain about method compliance and method is part of the finished, future-proof specification, is it safe to shim native object directly.
Enumerability
Another interesting, but likely insignificant difference is enumerability of shimmed methods. Unless methods are added using ES5 additions that allow to specify property enumerability (Object.defineProperty
or Object.defineProperties
), methods end up being enumerable:
if (!Array.prototype.map) {
Array.prototype.map = function() { /* ... */ };
}
Object.keys(Array.prototype); // ["map"]
// can be worked around:
if (!Array.prototype.map) {
Object.defineProperty(Array.prototype, 'map', {
value: function() { /* ... */ }
});
}
Object.keys(Array.prototype); // []
Underscore.js and API consistency
Getting back to underscore.js, I see an important aspect of consistency. Underscore adds not only standard methods like map
, reduce
and trim
, but also its own, custom ones — values
, extend
, clone
, etc. By adding map
, reduce
, and trim
to standalone object, it keeps its API consistent.
I’d like to also mention that I do extend Array.prototype
in fabric.js with methods like forEach
, map
, every
. I make sure those methods are spec-compliant, and I take a risk of conflicts with libraries that shim methods in non-compliant way. Methods that are non-standard, on the other hand, are defined under standalone utility object. I’m not worried much about inconsistency, since — unlike in underscore.js — there’s only a handful of shimmed methods.
So there you have it. It should be now clear that extending native built-ins is definitely not as risky as messing with host objects. Do it carefully, follow spec closely, and use your reasonable judgement. For a spec-compliant shims, MDN is a good place to start with (but don't trust it blindly either, as there were also cases of non-compliance there).
Did you like this? Donations are welcome
comments powered by Disqus