DEV Community

Daniel Worsnup
Daniel Worsnup

Posted on • Originally published at danielworsnup.com

Objects as Keys: A JavaScript Adventure

Cross-posted from my website's blog.

Let's dive right into some code:

const key1 = {}
const key2 = {}

const obj = {
  [key1]: 1,
  [key2]: 2
}

console.log(obj[key1], obj[key2]) // Prints 2, 2 instead of 1, 2

In this snippet we're using two objects (key1 and key2) as keys in another object (obj). It doesn't quite work as expected, and the reason for the actual behavior is simple: objects do not uniquely identify themselves when used as object keys. We'll dig into the details of this in a moment.

In JavaScript, objects were never intended to operate as keys in other objects. It isn't the way the language is designed, and it's impossible to use them this way out-of-the-box, as demonstrated by the previous code snippet. In the event that we do need this type of behavior, we can leverage a Map and be done with it:

const key1 = {}
const key2 = {}

const map = new Map()
map.set(key1, 1)
map.set(key2, 2)

console.log(map.get(key1), map.get(key2)) // Prints 1, 2

You're probably thinking, "Right. So why is the topic even open for discussion?" I'm glad you asked!

Exploring alternate, unorthodox solutions to problems, even when they involve some practices that are not recommended for production code, can lead to unexpected learning and insight. There is a time and place for asking the questions "What if?" and "If so, how?" This is why we are here. What if we could use objects as keys? How might it work?

In this post we will dig into this idea of using objects as keys without using a Map or Set. We will start with a rough working solution, identify issues, and iterate toward a robust implementation that covers a wide range of use cases. Along the way we will stumble upon and discuss a number of lesser-known language features, including automatic type coercion, prototype chains, property descriptors, and symbols.

If you're ready to learn more about JavaScript, let's get started!

Object Properties

Perhaps the simplest thing you can do to an object is to give it a property with some value. As with anything in software development, there are a number of ways do so. You can declare initial properties when you create an object:

const obj = {
  awesomenessLevel: 9001
}

Or, you can intialize properties after object creation using the assignment operator:

const obj = {}
obj.awesomenessLevel = 9001
// or
obj['awesomenessLevel'] = 9001

And a third way would be to call Object.defineProperty or Reflect.defineProperty, passing the object, a property name, and a property descriptor:

const obj = {}
Reflect.defineProperty(obj, 'awesomenessLevel', { value: 9001 })

In all of these cases, we would say that the string 'awesomenessLevel' is the property key and the number 9001 is the property value.

Note: The property created by the property descriptor above differs in a few significant ways from properties created by assignment and object instantiation. More on this later.

Key Types and Automatic Coercion

While a property's value can be any type, its key must be one of only two types: a string or a symbol. When using any other key type, the JavaScript runtime will first try to coerce, or force, the key to a string before using it as a property key:

const obj = {}
obj[1] = true
key = Object.keys(obj)[0]

console.log(key, typeof key) // '1', 'string'

As you can see, when we use the number 1 as a key, it gets coerced to the string '1' before being set as a property key.

When a key is not a string and cannot be coerced to a string, the JS runtime will throw a good ole TypeError your way. In practice, however, this is actually pretty rare because almost every value in JavaScript can be coerced to a string. Consider objects, for example.

By default, an object's prototype points to the global Object.prototype, which has a generic toString implementation. When trying to coerce an object to a string, the JS runtime will check the object and its prototype chain for toString functions. It will ultimately land on Object.prototype.toString if it doesn't find one any earlier in the prototype chain:

const key = {}
Object.getPrototypeOf(key) === Object.prototype // true
key.toString === Object.prototype.toString // true

console.log(key.toString()) // '[object Object]'

const obj = {
  [key]: true
}

console.log(obj) // { '[object Object]': true }

Object.prototype.toString returns the string '[object Object]', meaning that by default all objects are coerced to '[object Object]' when a string representation is needed. Unfortunately, this coercion behavior isn't very helpful when using objects as keys because it results in all objects having the same key! As a result, each time we assign a value to an object key it will overwrite the previous value, and only the most recent value will actually be stored:

const key1 = {}
const key2 = {}
const key3 = {}
const obj = {
  [key1]: 1,
  [key2]: 2,
  [key3]: 3
}

console.log(obj) // { '[object Object]': 3 }

This explains why the first code snippet did not work as expected.

Overriding the Default Coercion Behavior

Because the JS runtime coerces objects to strings when they are used as keys in other objects, we need every unique object to be coerced to a unique string (instead of being coerced to the default '[object Object]'). In order to accomplish this, we need a way to alter an object's default coercion behavior. There are a number of ways to do so, for example:

  1. We can create the object without a prototype, thereby severing the inheritance relationship with Object.prototype and removing the default toString implementation. As a result, the JS runtime's ability to coerce the object to a string is taken away, and we get a TypeError when using the object as a key:

    const key = Object.create(null)
    key.toString // undefined
    
    const obj = {
      [key]: true // TypeError
    }
    
  2. We can change the object's prototype to one with a different toString implementation:

    const myAmazingPrototype = {
      toString() { return 'hello' }
    }
    
    const key = {}
    Object.setPrototypeOf(key, myAmazingPrototype)
    
    const obj = {
      [key]: true
    }
    
    console.log(obj) // { 'hello': true }
    

    We could have also used Object.create here:

    const key = Object.create(myAmazingPrototype)
    

    Notice that the object continues to inherit other default object properties from Object.prototype because it's still connected through myAmazingPrototype's prototype. We've simply added one more level to the chain of prototypes:

    Object.getPrototypeOf(key) === Object.prototype // false
    Object.getPrototypeOf(Object.getPrototypeOf(key)) === Object.prototype // true
    key.valueOf // ฦ’ valueOf() { [native code] }
    
  3. We can define toString directly on the object. The object's own implementation of toString takes priority over that of Object.prototype because the JS runtime finds it first when searching the object and its prototype chain:

    const key = {
      toString() { return 'hello' }
    }
    const obj = {
      [key]: true
    }
    
    console.log(obj) // { 'hello': true }
    
  4. We can define Symbol.toPrimitive on the object. This symbol enables us to completely bypass the JS runtime's default algorithm for coercing an object to a primitive. This default algorithm searches the object and its prototype chain for toString or valueOf functions and throws a TypeError when neither can be found.

    const key = {
      [Symbol.toPrimitive]() { return 'hello' }
    }
    const obj = {
      [key]: true
    }
    
    console.log(obj) // { 'hello': true }
    

All of these approaches enable us to override object-to-primitive coercion behavior on individual objects, but we still don't quite have what we need.

Overriding Coercion For All Objects

Instead of overriding the behavior on individual objects, we want all objects to inherit the overridden behavior by default. We can then create objects with object literal syntax and use those objects as keys without having to make changes to the object or its prototype chain. To that end, let's define Symbol.toPrimitive on Object.prototype:

Object.prototype[Symbol.toPrimitive] = () => 'hello'
const key = {}
const obj = {
  [key]: true
}

console.log(obj) // { 'hello': true }

As you can see, the key object was coerced to 'hello' without us having to do anything special to it.

Note: In the introduction to this post, I mentioned that our solution would incorporate practices that are not recommended for production code, and I was referring specifically to this. I don't recommend making changes to Object.prototype in real-world applications except when polyfilling standard features. Recall that we embarked on this adventure with the primary goal of learning more about JavaScript.

The next step is the fun part!

Generating Unique IDs

Our Symbol.toPrimitive function needs to return a unique string for each unique object. In order to accomplish this, we need some way to generate a unique identifier for every object. Let's call this identifier the object ID. The question is, how do we get such an ID?

When I first tried to solve this problem, my initial thought was that these IDs could be derived simply by "stringifying" the objects:

Object.prototype[Symbol.toPrimitive] = function() {
  return JSON.stringify(this)
}

This solution indeed works for some use cases:

const key1 = { a: 1 }
const key2 = { b: 2 }

const obj = {
  [key1]: 1,
  [key2]: 2
}

console.log(obj[key1], obj[key2]) // 1, 2

But it has the following major limitations:

  1. Objects that contain identical key/value properties produce the same ID because they stringify to the same string. Hence we don't have guaranteed unique keys.
  2. An object's ID will change over time as its keys and values change. As a result, every object would have to be treated as immutable.
  3. Objects that contain functions or circular references cannot be used as keys because these objects cannot be stringified.
  4. Stringification becomes more expensive as the size of an object grows. There could be use cases where JSON.stringify is called repeatedly because objects are being coerced to strings repeatedly.

We need something much better. Instead of trying to derive the ID from an object's contents, we can assign an ID to an object the first time that Symbol.toPrimitive is called on it, and we can store this ID on the object itself so that it can be recalled and returned for future coercions.

Let's start by assigning the same ID to every object that Symbol.toPrimitive is called on:

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    this.id = 'hello'
    return this.id
  }
}

Notice a few things about these changes:

  1. Our function is a regular function expression rather than an arrow function. This is extremely important because of how arrow functions affect the this context of the function. We need this to refer to the object that Symbol.toPrimitive was called on, but using an arrow function causes this to refer to whatever this refers to outside of the function. In fancy terms, this would get inherited from the surrounding lexical context.
  2. We've added a check around Symbol.toPrimitive's hint parameter. Because the JS runtime uses Symbol.toPrimitive for more than just string coercion, hint can be any of the values 'string', 'number', and 'default'. For our use case, we only need to handle the string case.

Let's see what happens when our new function is invoked:

const obj = {}
console.log(obj) // {}
console.log(`${obj}`) // 'hello'
console.log(obj) // { 'id': 'hello' }

It works, but we're going to want to find a better way to associate the ID with the object. We'll look at why and how in a moment. First, let's start assigning unique IDs!

We can use an integer for the object ID and use a global variable to track what the next object ID will be. Each time we assign an object ID, we increment the "global ID counter", which we'll name nextObjectId:

let nextObjectId = 0

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    if (this.id === undefined) {
      this.id = nextObjectId++ // assign first, then increment
    }
    return this.id
  }
}

The if(this.id === undefined) { ... } conditional ensures that we only assign an ID (and increment the global counter) the first time that Symbol.toPrimitive is called on the object. If an ID is already assigned, we skip this step and immediately return the ID.

Let's see how things are looking by running our first code snippet again:

const key1 = {}
const key2 = {}
const obj = {
  [key1]: 1,
  [key2]: 2
}
console.log(obj[key1], obj[key2]) // 1, 2

It works! Even with this simple Symbol.toPrimitive function we are already able to use objects as keys. However, there are still a few significant improvements to be made. Let's take a closer look at key1 and key2 after the previous snippet ran:

console.log(key1) // { 'id': 2 }
console.log(key2) // { 'id': 3 }

In my console the assigned IDs ended up being 2 and 3. Your results may differ, but the important detail is that they should be two unique, consecutive integers.

We'll make three improvements before calling it a day.

Hiding the Object ID From Enumeration

First, it's not ideal that an object's ID is stored as a normal property on the object. The ID will show up when enumerating the object's keys and will, for instance, get copied to another object when spreading:

const anotherKey = { ...key1 }
console.log(anotherKey) // { 'id': 2 }

We're now in a situation where two objects have the same ID. According to our Symbol.toPrimitive implementation, key1 and anotherKey coerce to the same string, and thus we no longer have a unique ID for each object:

obj[anotherKey] = 3
console.log(obj[key1]) // should be 1, but we get 3

To fix this, we need Symbol.toPrimitive to associate the ID with the object in a way that is as "invisible" as possible. The ID shouldn't show up during key enumeration (i.e. Object.keys) or when shallow cloning an object using Object.assign or spread syntax. We could make the ID completely invisible by storing it in a Map or WeakMap:

const objectIdsMap = new WeakMap()

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    if (!objectIdsMap.has(this)) {
      objectIdsMap.set(this, nextObjectId++)
    }
    return objectIdsMap.get(this)
  }
}

However, the whole point of this post is to mimic the behavior of Maps and Sets without using Maps and Sets. And so we resort to the next best thing: property descriptors! Let's use Reflect.defineProperty to create an ID property that doesn't show up during enumeration:

let nextObjectId = 0

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    if (this.id === undefined) {
      Reflect.defineProperty(this, 'id', {
        value: nextObjectId++,
        enumerable: false // this is the default
      })
    }
    return this.id
  }
}

We actually don't have to specify enumerable: false in the descriptor object because false is the default value for enumerable. We'll leave this part out of upcoming code snippets.

Now that our ID is non-enumerable, it's much more invisible than it was before and won't get copied to other objects:

const key = {}
`${key}` // 2
Object.keys(key) // []

const anotherKey = { ...key }
`${anotherKey}` // 3
Object.keys(anotherKey) // []

There are still ways to see the ID property, and without using a Map/WeakMap we will not be able to get around this:

  1. When you inspect an object in the browser's developer tools, you'll see the non-enumerable properties along with an indicator to distinguish them from enumerable properties. In Chrome, for example, non-enumerable properties are slightly faded out. These features are useful for debugging code that utilizes non-enumerable properties.
  2. The Object.getOwnPropertyNames function returns all of an object's own property names, including those that are non-enumerable:

    Object.getOwnPropertyNames(key) // ['id']
    

Nevertheless, we've hidden the ID property well enough for most use cases. Let's move on to the second improvement.

Preventing Collisions With the Hidden ID Property

There is another problem with the ID property that we need to fix: we've made it impossible for any other code to utilize an id property on objects that are used as keys. There are many use cases for having a property named id on an object, and we need to support them. Consider the following code:

const key = {}
`${key}` // 2
key.id = 'anotherId'
console.log(key.id) // 2

The assignment of a new value to the id property failed, and what's worse is that no error was thrown to indicate that it failed! If you aren't careful, behavior like this can result in bugs that are very difficult to troubleshoot. Let's examine why the assignment failed. Recall the property descriptor that our Symbol.toPrimitive function uses:

Reflect.defineProperty(this, 'id', {
  value: nextObjectId++,
})

Because we did not include writable in the property descriptor, it defaulted to false, meaning that the id property cannot be changed with an assignment operator. There are two ways to detect when property assignments fails:

  1. In strict mode, assigning to a non-writable property throws a TypeError:

    (() => {
      'use strict'
      var key = {}
      Reflect.defineProperty(key, 'id', { value: 2 })
      key.id = 'anotherId' // TypeError
    })()
    
  2. Reflect.set will return false when a property assignment fails:

    Reflect.set(key, 'id', 'anotherId') // false
    

We could make id a writable property, but this not ideal because it enables other code to potentially change an ID assigned by Symbol.toPrimitive. As a result, we lose the guarantee that object IDs are unique and stable.

The issue we're facing isn't that other code cannot assign to the id propertyโ€”it's that our object keying system is utilizing a property named id in the first place. We need to prevent collisions with our ID property and enable other code to use any property name without conflicting with the object ID. We have several options for doing so:

  1. Use a more obscure property name for the object ID. This isn't a terrible idea. We could minimize the risk of collisions significantly by using a name like __object_id__ab8dfbbd4bed__, where we've even included some random characters in the property name. But let's see if we can do even better!
  2. Use a symbol for the ID property. Because symbols are guaranteed to be unique, this is the perfect use case for one.

Here's the updated code, adjusted to use a symbol for the object ID:

const ID_SYMBOL = Symbol()
let nextObjectId = 0

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    if (this[ID_SYMBOL] === undefined) {
      Reflect.defineProperty(this, ID_SYMBOL, {
        value: nextObjectId++,
      })
    }
    return this[ID_SYMBOL]
  }
}

With this change, other parts of the codebase and even other libraries are free to use any object property name without risking a collision with our ID property. Also, the ID property will now be returned from Object.getOwnPropertySymbols instead of Object.getOwnPropertyNames:

Object.getOwnPropertyNames(key) // []
Object.getOwnPropertySymbols(key) // [Symbol()]

Giving our symbol a "description" can help with debugging without impacting the behavior or uniqueness of the symbol:

const ID_SYMBOL = Symbol('Object ID')

// ...

Object.getOwnPropertySymbols(key) // [Symbol(Object ID)]

Now that our object ID property is safe from being seen or altered by other code, let's move on to the third improvement.

Mitigating Collisions With Non-Object Keys

The IDs produced by Symbol.toPrimitive are guaranteed to uniquely identify an object as a key as long as only objects are used as keys. However, there may be use cases that require an object to contain both object and non-object keys. Consider the following code:

const key1 = {}
const key2 = {}
const key3 = 2

const obj = {
  [key1]: 1,
  [key2]: 2,
  [key3]: 3
}

console.log(obj[key1], obj[key2], obj[key3]) // Prints 3, 2, 3 instead of 1, 2, 3

The code doesn't behave as expected because key1 and key3 coerce to the same string, '2'. To fix this, objects need to coerce to keys that are globally unique across all data types, instead of being unique only among other objects. Let's look at two solutions.

Object ID Prefix

We can make our object IDs more globally unique by prefixing them with an obscure string, such as '__object_id__' or '__object_id__ab8dfbbd4bed__':

const ID_SYMBOL = Symbol('Object ID')
const ID_PREFIX = `__object_id__`
let nextObjectId = 0

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    if (this[ID_SYMBOL] === undefined) {
      Reflect.defineProperty(this, ID_SYMBOL, {
        value: `${ID_PREFIX}${nextObjectId++}`
      })
    }
    return this[ID_SYMBOL]
  }
}

Running the last code snippet now produces the expected results because key1 now coerces to '__object_id__2' and no longer conflicts with key3, which coerces to '2'.

The ID prefix is a solid solution and suffices for most use cases. However, while this solution significantly reduces the probability of collisions, it doesn't eliminate the issue entirely. Some of you may already know where this is going!

Object ID Symbols

Instead of using a global ID counter (nextObjectId), each object's ID can be its own unique symbol:

const ID_SYMBOL = Symbol('Object ID')

Object.prototype[Symbol.toPrimitive] = function(hint) {
  if (hint === 'string') {
    if (this[ID_SYMBOL] === undefined) {
      Reflect.defineProperty(this, ID_SYMBOL, {
        value: Symbol()
      })
    }
    return this[ID_SYMBOL]
  }
}

By using symbols, we have handed off to the browser the responsibility of creating unique keys. These keys will be unique across the space of all primitives coerced from JavaScript values.

Unfortunately, there is one major caveat to this approach: property symbols are not included in the return value of Object.keys:

const key1 = {}
const key2 = {}

const obj = {
  [key1]: 1,
  [key2]: 2
}

console.log(obj[key1], obj[key2]) // 1, 2 ๐Ÿ‘
console.log(Object.keys(obj)) // [] ๐Ÿ‘Ž

For this reason, the ID prefix approach may be superior.

And that's all! It took us a number of iterations to get here, but we landed on a simple Symbol.toPrimitive function that, when applied to Object.prototype, enables the use of objects as keys.

The Journey Is More Important Than The Destination

We can learn a lot from delving into an unconventional approach to solving a problem. Even though the final code for our object keying system shouldn't be used in production, I hope you learned from the journey we took to build it.

We discussed a number of ideas and language features. We saw problems with our solution and made improvements until we ended up with something robust and functional. Let's revisit the highlights:

  1. Object keys must be strings or symbols. Keys of any other type will be coerced to an allowable type, and an error will be thrown if this fails.
  2. We can use Symbol.toPrimitive to override the default algorithm for coercing an object to a primitive, and we can apply this symbol to Object.prototype to declare the override for all objects.
  3. We can use Reflect.defineProperty or Object.defineProperty to define non-enumerable properties on an object. These properties won't be returned from Object.keys or get copied when using Object.assign/spread. Property descriptors enable a number of other behaviors as well. See the MDN docs for details.
  4. When we need truly unique property keys with a zero chance of collisions, symbols are the way to go! But keep in mind that they are not included in the return value of Object.keys.

One Last Thing

The polyfill library core-js is commonly used when developing for browsers that do not natively support certain standard JavaScript features. Not surprisingly, it contains polyfills for Map and Set. Our final approach for enabling objects as keys is strongly based on the implementation of these polyfills. Specifically, core-js utilizes property descriptors and non-enumerable properties to store an object's ID on the object itself. Further, the IDs are generated by an incrementing counter variable!

That's All!

We covered a lot of ground in this post. If you made it all the way through, thanks for reading! I sincerely hope you learned a thing or two about JavaScript that you didn't know before.

Happy coding!

Like this post?

Follow me on Twitter where I tweet about frontend things: @worsnupd

Latest comments (0)