Posted 2020-02-12.
I haven’t done a ton of work in javascript, and I’ve generally only needed a superficial knowledge of it. Recently I wanted to make something that would let me run code in the browser and display what happened at every step of its execution. Javascript was the obvious choice for this, but I needed to learn a lot more about the language. Here’s an assortment of factoids that stood out to me along the way.
Focusing on weird or suprising bits may come across as negative, but actually this project improved my opinion of javascript dramatically. It may lack the conceptual elegance of some languages, but ES2015 and later are perfectly pleasant to work in.
Let’s start with a tidbit that’s probably widely known: calling sort on an array sorts the elements as strings by default. Remember that or you’ll get surprising results when you sort an array of numbers.
[1, 2, 10].sort() // => [1, 10, 2]
Also well-known is that NaN isn’t equal to itself according to ===
.
NaN === NaN // => false
You might expect this to cause problems if you use NaN as a key in a Map, but map keys actually use a slightly different definition of equality in which NaN does equal itself.
const m = new Map(); m.set(NaN, 'hi!'); m.get(NaN) // => 'hi!'
Symbols are useful for when you want to attach a property to an object without worrying that its name might collide with other properties.
The existence of symbol properties means Object.getOwnPropertyNames
alone doesn’t give you a complete list of an object’s properties.
const obj = { str: 'foo', [Symbol('sym')]: 'bar', }; Object.getOwnPropertyNames(obj) // => [ 'str' ] Object.getOwnPropertySymbols(obj) // => [ Symbol(sym) ]
The selling point of symbols is that they can be unique even when their descriptions collide:
const sym1 = Symbol('foo'); const sym2 = Symbol('foo'); sym1 === sym2 // => false sym1 === sym1 // => true const obj = { [sym1]: 'a', [sym2]: 'b', }; Object.getOwnPropertySymbols(obj) // => [ Symbol(foo), Symbol(foo) ]
I wanted my tracer to display as much information as possible about the values it traces, which means it should not only show a symbol’s description, but also enable you to tell whether two symbols traced at different points would be considered equal or not. To accomplish that I had the serializer keep a map of all symbols it encounters and assign an ID to each one.
One weird bit is that symbols are totally immutable, but quietly ignore attempts to change them.
const sym = Symbol('foo'); sym.description // => 'foo' sym.description = 'bar'; sym.description // => 'foo' sym.x = 'y'; sym.x // => undefined
In fact, most primitive types seem to behave that way, except for null
and undefined
:
const str = 'hi'; str.x = 'y'; str.x // => undefined const num = 3; num.x = 'y'; num.x // => undefined const bool = true; bool.x = 'y'; bool.x // => undefined const nullval = null; nullval.x = 'y'; // throws TypeError const undefval = undefined; undefval.x = 'y'; // throws TypeError
I was pleasantly surprised to learn javascript now has a WeakMap type. This allowed me to write an encoder/serializer for arbitrary objects that assigns consistent IDs to each object over time, without leaking memory1.
I was unpleasantly surprised to learn that WeakMaps can’t use symbols for keys:
const wm = new WeakMap(); wm.set(Symbol('foo'), 'bar'); // throws TypeError
My encoder has to remember each symbol in case it appears again, and has no way to signal to the garbage collector that the map entry for a given symbol only matters if there are other references to that symbol too. So, if a program were to continuously create new symbols and pass them to my encoder, the program would eventually run out of memory. Bummer. Not likely to come up, though.
As you may know, javascript binds the this keyword based on the calling code. For example:
const obj = { name: 'world', greet() { console.log('hello ' + this.name); } }; obj.greet() // => hello world const f = obj.greet; f() // => hello undefined (or TypeError if you're in strict mode)
Now, it’s clear what this code will do:
const obj = { name: 'world', get greeting() { return 'hello ' + this.name; } }; obj.greeting // => 'hello world'
But it’s less obvious, at least to me, that the following will do the same:
const obj = { name: 'world', get greetingMaker() { return function() { return 'hello ' + this.name; } } }; obj.greetingMaker() // => 'hello world'
Apparently, given x.y()
, where y
refers to a getter function and that getter itself returns a function, javascript binds this
to x
for both the invocation of the getter function and the invocation of the function returned by the getter.
Javascript is highly dynamic; you’re free to change the behavior of a lot of built-in things.
Consider the apply method:
function greet() { console.log('hi!'); } greet(); // => hi! greet.apply(); // => hi!
What if we alter it?
greet.apply = () => console.log('go away.'); greet(); // => hi! greet.apply(); // => go away.
OK, so that affects explicit calls to .apply
, but has no deeper implications.
Boring.
But what if we try to change it everywhere?
Function.prototype.apply = () => console.log('GO AWAY'); greet(); // => GO AWAY greet.apply(); // => GO AWAY new Date() // => GO AWAY 'hello world'.toUpperCase() // => GO AWAY
Oops. We broke all function calls. Think of all the security holes we just plugged, though!
(This is the behavior in Node.js - it’s not the same everywhere.)
The bind method is often used to wrap a function so that this
will always have the same value no matter how the function is called.
const obj = { name: 'world', greet() { console.log('hello ' + this.name); } }; const fn = obj.greet.bind(obj); fn() // => hello world
If you’re inspecting a bound function (say, for troubleshooting), there are some obvious things you’d want to know:
this
?I would like my tracer to serialize all that information.
Unfortunately, javascript doesn’t seem to provide any way to do that.
Bound functions do not have properties pointing to the original function or any of the bindings.
The only hint you can get is that their names start with 'bound '
.
obj.greet.toString() // => "greet() {\n console.log('hello ' + this.name);\n }" obj.greet.bind(obj).toString() // => 'function () { [native code] }' obj.greet.name // => 'greet' obj.greet.bind(obj).name // => 'bound greet'
Another oddity: you can call bind
on a bound function, but the binding for this
is set in stone after the first bind.
function greet() { console.log('hello ' + this.name); } const obj1 = { name: 'world' }; const obj2 = { name: 'Newman' }; greet.bind(obj1)() // => hello world greet.bind(obj2)() // => hello Newman greet.bind(obj1).bind(obj2)() // => hello world
Bindings for other arguments accumulate, though:
function add(a, b) { return a + b; } add.bind(null, 1)(2) // => 3 add.bind(null, 1).bind(null, 2)() // => 3
I wanted the serializer for my tracer to record all the details about any object it encounters, without accidentally invoking any code associated with the object.
This means, for example, that it uses Object.getOwnPropertyDescriptor(obj, key)
instead of obj[key]
, so that if the key refers to a getter, the getter won’t be called.
For better or worse, there’s at least one type of object for which this passive inspection is unachievable: proxies.
const obj = { description: 'jejune' }; let count = 0; const proxy = new Proxy(obj, { get(target, prop) { count++; return obj[prop]; }, getOwnPropertyDescriptor(target, prop) { count++; return Object.getOwnPropertyDescriptor(target, prop); }, }); proxy.description // => 'jejune' Object.getOwnPropertyDescriptor(proxy, 'description').value // => 'jejune' count // => 2
To my knowledge, there’s not a reliable cross-platform way to determine if an object is a Proxy or to get access to the target or handler without invoking the handler.
However, as someone pointed out on StackOverflow, you can redefine the Proxy
constructor itself to let you spy on any subsequently-created proxies:
const knownProxies = new WeakMap(); Proxy = new Proxy(Proxy, { construct(func, args) { const proxy = new func(...args); knownProxies.set(proxy, args); return proxy; } }); function isProxy(obj) { return !!knownProxies.get(obj); } function getProxyTarget(obj) { return knownProxies.get(obj)[0]; } function getProxyHandler(obj) { return knownProxies.get(obj)[1]; }