Objects as Keys: A JavaScript Adventure
Tags: javascript, tutorial, webdev
Published: 2019-10-19 03:09:21

_Cross-posted from [my website's blog](https://www.danielworsnup.com/blog/objects-as-keys-a-javascript-adventure/)._


Let's dive right into some code:

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) and be done with it:

```javascript
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:

```javascript
const obj = {
  awesomenessLevel: 9001
}
```

Or, you can intialize properties after object creation using the [assignment operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Assignment_Operators):

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

And a third way would be to call [Object.defineProperty](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) or [Reflect.defineProperty](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/defineProperty), passing the object, a property name, and a _property descriptor_:

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). When using any other key type, the JavaScript runtime will first try to [_coerce_](https://developer.mozilla.org/en-US/docs/Glossary/Type_coercion), or force, the key to a string before using it as a property key:

```javascript
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](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes) 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_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:

```javascript
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:

```javascript
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:
    ```javascript
    const key = Object.create(null)
    key.toString // undefined

    const obj = {
      [key]: true // TypeError
    }
    ```
1. We can change the object's prototype to one with a different `toString` implementation:
    ```javascript
    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:
    ```javascript
    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:
    ```javascript
    Object.getPrototypeOf(key) === Object.prototype // false
    Object.getPrototypeOf(Object.getPrototypeOf(key)) === Object.prototype // true
    key.valueOf // ƒ valueOf() { [native code] }
    ```
1. 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:
    ```javascript
    const key = {
      toString() { return 'hello' }
    }
    const obj = {
      [key]: true
    }

    console.log(obj) // { 'hello': true }
    ```
1. We can define [`Symbol.toPrimitive`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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.
    ```javascript
    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`:

```javascript
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:

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

This solution indeed works for some use cases:

```javascript
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.
1. 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.
1. Objects that contain functions or circular references cannot be used as keys because these objects cannot be stringified.
1. 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:

```javascript
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](https://developer.mozilla.org/en-US/docs/web/JavaScript/Reference/Operators/function) rather than an [arrow function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions). 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_.
1. 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:

```javascript
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`:

```javascript
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:

```javascript
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:

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax):

```javascript
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:

```javascript
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`:

```javascript
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 `Map`s and `Set`s _without_ using `Map`s and `Set`s. 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:

```javascript
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:

```javascript
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.
1. The [`Object.getOwnPropertyNames` function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames) returns all of an object's own property names, including those that are non-enumerable:
    ```javascript
    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:

```javascript
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:

```javascript
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`:
    ```javascript
    (() => {
      'use strict'
      var key = {}
      Reflect.defineProperty(key, 'id', { value: 2 })
      key.id = 'anotherId' // TypeError
    })()
    ```
1. `Reflect.set` will return `false` when a property assignment fails:
    ```javascript
    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!
1. __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:

```javascript
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`:

```javascript
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:

```javascript
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:

```javascript
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__'`:

```javascript
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:

```javascript
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`:

```javascript
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.
1. 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.
1. 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty#Description) for details.
1. 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](https://github.com/zloirock/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](https://twitter.com/worsnupd)