Debug session: Javascript, toString, object, maps e outras porcarias

Fun debugging session that happened at $WORK.

I found that a component, published to npm was crashingwhen giving a certain input.

Since it was minified, it was hard to see where the error was. I added the sourcemap. It happened in a loop, a certain property which was expected to be an array, ended up being a Number:

1
existingNode.total[0] += currentNode.total[0];

Firefox error message was kinda confusing:

1
Uncaught TypeError: can't assign to property 0 on 288: not an object

Chrome’s was better:

1
TypeError: Cannot create property '0' on number '144'

That’s the thing with TypeScript: types are not real. An allusion to the “is x in the room with us right now” meme

Anyway, after some debugging I found that it happened when the string toString is used. I handcrafted a minimum reproducible example, which helped me debug. Debuggers are cool, but Conditional Breakpoints often let me down.

To give some context, the code iterates a tree and merges nodes’ values with the same name. It’s a feature for sandwich viewing if you are interested.

What I found curious is that when I logged the node, it told me it was a function with properties!

1
2
3
4
5
6
function toString()
length: 0
name: "toString"
self: 8
total: 72
type: "single"

This is something I kinda forgot about, but why wouldn’t be possible?

1
2
3
4
5
function myFn() {}
myFn.foo = 'bar';

> myFn().foo
'bar'

Back in the day we used to write constructors using functions:

1
2
3
4
function Person() { this.name = 'John'; }

> new Person().name
'John'

And of course, ES6 classes have some syntax sugar over this implementation (but not only that!).

Anyway, what was happening is that we created a map-like object. And then to not initialize twice, we did a check for the presence of an existing item:

1
childrenMap[node.children[i].name] ||= node.children[i];

However, since toString always exist in an object, it wasn’t being initialized correctly!

Then it would spiral in some crazy madness I won’t go deep about.

The solution was to just replace the map-like object for a real Map:

A Map does not contain any keys by default. It only contains what is explicitly put into it.

An Object has a prototype, so it contains default keys that could collide with your own keys if you’re not careful.

I didn’t explain where the properties of toString were coming form, to figure out I used a handy Proxy:

1
2
3
4
5
Object.prototype.toString = new Proxy(Object.prototype.toString, {
  set: () => {
    debugger;
  },
});

Whenever toString is set (ie overridden), the debugger is called.

With that I managed to find the offending code:

1
2
3
4
5
6
hash[name] = hash[name] || { name: name || '<empty>' };

// latter on hash[name] becomes c
c.type = 'single';
c.self = zero(c.self) + ff.getBarSelf(level, j);
c.total = zero(c.total) + ff.getBarTotal(level, j);

So if name is for example, toString, hash[name] returns the function from the prototype chain, ie from Object.prototype.toString, which attributes are added, leaking to every single object.