We’re Hiring!

New Bamboo Web Development

Bamboo blog. Our thoughts on web technology.

Into the unknown: Object.observe and Object.freeze in JavaScript

by Lee Machin

One of the true joys of programming is being able to deconstruct the language you work with using nothing but the language itself. This isn't necessarily concerned with meta-programming, macros, reflection, and so on; but with the ability to experiment with its behaviour to learn more about what goes on under the hood. I hadn't really given this much thought until I came across a certain JavaScript library.

Earhorn, in the words of the author, shows you a "detailed, reversible, line-by-line log of JavaScript execution". It's pretty neat and draws comparisons with the style of development demonstrated by Bret Victor in his Inventing on Principle talk. The problem is you have to actually alter your code so the library is able to do its stuff, which restricts your ability to quickly drop in the script and let it get to work.

I wondered if there was a way to get around that, so you could observe your code without changing it to make it observable.

I see what you did last summer

There is a tiny function that is part of the much-anticipated ES6 standard that could allow me to do exactly that: Object.observe. Supported in Chrome, and only by enabling Experimental JavaScript in chrome://flags, it allows you to receive events from an object whenever it changes. Try it out:

 1 var person = { firstName: 'Noel', lastName: 'Edmonds' }
 2 
 3 Object.observe(person, function (changes) {
 4   changes.forEach(function (change) {
 5     console.log("Property '%s' changed from '%s' to '%s'", change.name, change.oldValue, change.object[change.name])
 6   })
 7 })
 8 
 9 person.firstName = 'Mr' //=> Property 'firstName' changed from 'Noel' to 'Mr'
10 person.lastName  = 'Blobby' //=> Property 'lastName' changed from 'Edmonds' to 'Blobby'

What would happen, I thought to myself, if you observed the global scope? MDN's description of var states that the global scope is bound to the global object. This would suggest that declaring a variable (either with var at the top level, or without var inside a function) simply adds a new property to window (or an equivalent outside of the browser). Maybe we could take advantage of this somehow.

 1 var logChanges = function (obj) {
 2   return Object.observe(obj, function (changes) {
 3     changes.forEach(function (change) {
 4       console.log("Property '%s' changed from '%s' to '%s'", change.name, change.oldValue, change.object[change.name])
 5     })
 6   })
 7 }
 8 
 9 logChanges(window)
10 
11 window.hello = "world" //=> Property 'hello' changed from 'undefined' to 'world'
12 
13 var areYouThere = confirm("Are you there?") //=> Property 'areYouThere' changed from 'undefined' to 'true'

The trick here is that var (or let if you prefer), when executed in the global scope, is roughly equivalent to hitting your beautiful, majestic code with the ugly stick, and calling Object.defineProperty instead.

1 var foo = "bar"
2 
3 Object.defineProperty(window, 'foo', {
4   value: "bar",
5   enumerable: false
6 )}

Sadly, the global object is unique in this respect, because the same assumptions cannot be made about any other scope. Local variable declarations are unobservable, and we can only keep track of properties added to an object or defined on its prototype. This somewhat limits the utility of using Object.observe to, say, make Earhorn less intrusive, or to allow it to work without significantly changing your coding style. This isn't a slight on the power and applicability of the function as a whole; simply that the ideal use-case relies on a side-effect that doesn't exist anywhere else.

Of course, if you used sweet.js, then you could use a macro instead. It'd probably look something like this (and be broken) if you threw it together in 2 minutes like I did. Problem solved.

 1 macro variable {
 2   rule { $($name:ident = $val:expr) (,) ... } => {
 3     $(Object.defineProperty(this, $name, {
 4       value: $val,
 5       enumerable: false
 6     });) ...
 7   }
 8 }
 9 
10 variable foo = "bar", baz = "quux", ...

Ice, ice baby

Not quite ready to dive into that particular rabbit hole, though, and still feeling quite inspired by what I'd just learned, I continued with my experimentation with plain old JS and turned my attention to Object.freeze. Immutability, a much adored aspect of many a functional programming language, and the enabler of concurrent programming, is a big deal lately. Om is a ClojureScript wrapper over React that takes advantage of immutability to improve the performance of diffing changes to the custom DOM that React maintains, before applying them to the page. Mori, by the same author, makes ClojureScript's persistent data structures available to plain old JS. The problem is, these libraries still allow you to get things done. Surely making the entire global scope immutable would create the safest possible working environment?

Keeping in mind how variables work in the global scope, let's see what sort of hilarity ensues when we try to freeze it.

1 Object.freeze(window)

Firefox throws an exception, which appears to be a bug by all accounts.

1 Object.freeze(window) // TypeError: can't change object's extensibility
2 Object.isExtensible(window) // true - wut?

Safari behaves completely unpredictably and tries to redirect you to a different page...

Chrome, on the other hand, is perfectly fine with it, which appears to be correct behaviour if you look at the spec. Let's see what happens:

 1 Object.freeze(window)
 2 
 3 var didItWork = true
 4 
 5 function add (a, b) {
 6   return a + b
 7 }
 8 
 9 console.log(didItWork) //=> ReferenceError: didItWork is not defined
10 
11 console.log(add(1, 2)) //=> ReferenceError: add is not defined

That's one way to solve the problem of ommitting var by accident without creating a new compile-to-JS language or sticking "use strict" in your code. And it even ignores function statements. Bonus!

Operating under the illusion that Chrome is right and all the other browsers are wrong, and making our best effort to write cross-browser incompatible code, maybe we could export this as a function:

 1 var $ = function (callback) {
 2   Object.freeze(window)
 3   var onReady = function () {
 4     var windowCopy = Object.create(window)
 5     return callback.apply(windowCopy)
 6   }
 7 
 8   return jQuery(onReady)
 9 }
10 
11 $(function (window) {
12   accidentalGlobalVar = "nooooo!"
13   console.log(accidentalGlobalVar) //=> ReferenceError: accidentalGlobalVar is not defined
14 })
15 
16 
17 $(function (window) {
18   window.version = 'Millenium Edition'
19   console.log(window.version) //=> Millenium Edition
20 })

Now you can rest safe in the knowledge that every function you define has its own, fresh copy of the global scope (sort of), and you can't do anything with the real thing. Is this useful? I don't know. Would you make any friends slipping it into your code base? Probably not. But, if this is still the thing for you, you can download yourself a copy of immutable.js and go wild.

So...?

The Coen brothers are as great at ending their films with evocative observations of existentialism as they are depicting bungling kidnappers and hit-men. I'm not, which means there has to be some sort of point to all this other than just mucking about, right? Of course there is!

Fellow Bambino Neil recently wrote about learning and getting undaunted, and I felt like expanding upon his ideas but from the perspective of actually discovering something and wanting to show it off. Your choice in such a situation is simple: play it down as knowing something that is apparently common knowledge, or talk about it and be proud because actually it's not. Whatever your motivation is - to see how something works under the hood, or to just become more familiar with the language - your environment is a playground, and each experiment an adventure into the unknown. What you learn might not be new to some, but to yourself and many others at many different skill levels, it most definitely is, and it's always worth sharing.

This, I hope, is one example of doing exactly that.