Improved and safer alternative to Object.create(null)

Even though the newer Map class has been around for a while now, plain old javascript objects are still often being used as a map, perhaps due to the native syntax support they have, making them much less verbose to use. However, one has to be careful when using plain objects as maps as all objects will inherit properties, such as toString(), that shouldn't be in the mapping. For this reason, it has become a common pattern to create an object without a prototype using Object.create(null), but even this still has a few pitfalls of its own (albeit, much more minor).

One such pitfall is that these objects do not know how to convert themselves to a primitive, so they will throw errors in scenarios such as these:

map = Object.create(null)
console.log(`Value: ${map}`) // Uncaught TypeError: Cannot convert object to primitive value
console.log(0 + map) // Uncaught TypeError: Cannot convert object to primitive value

I'm proposing that we provide a more user-friendly way to use objects as maps: Object.createMap() - I'll refer to these kinds of maps as "Property Maps" to distinguish them from instances of the Map class.

Object.createMap() is intended to replace Object.create(null) usage. It will return an object with a very slim prototype (that itself inherits from null, not Object.prototype). This prototype will never contain string keys, but it does define some symbols. For example, it can provide a definition for Symbol.toPrimitive to allow string/number coercion to behave in a normal way:

const map = Object.createMap()
console.log('As String: ' + map) // As String: [object PropertyMap]
console.log(+map) // NaN

Object.createMap() could conveniently provide the option to convert an existing object into a property map, by passing in that object as the first parameter.

const map = Object.createMap({ a: 1, b: 2 })
console.log(map.a) // 1

We could define Symbol.iterator, to let one iterate over the entries of the property map.

const map = Object.createMap({ a: 1, b: 2 })
for (const [key, value] of map) {
  console.log(`${key}=${value}`) // 'a=1' and 'b=2'
}

Providing Symbol.iterator in conjunction with the iterator-helpers proposal could provide some powerful tools to lazily work with an object's entries.

The following would be an example implementation of Object.createMap()

const propMapPrototype = Object.create(null, {
  [Symbol.toPrimitive]: {
    enumerable: false,
    value(hint) {
      if (hint === 'number') return NaN
      return `[object ${this[Symbol.toStringTag]()}]`
    },
  },
  [Symbol.toStringTag]: {
    enumerable: false,
    value() {
      return 'PropertyMap'
    },
  },
  [Symbol.iterator]: {
    enumerable: false,
    *value() {
      for (const key in this) {
        if (Object.prototype.hasOwnProperty.call(this, key)) {
          yield [key, this[key]]
        }
      }
    },
  },
})

Object.defineProperty(Object, 'createMap', {
  writable: true,
  configurable: true,
  value(properties = {}) {
    return Object.assign(Object.create(propMapPrototype), properties)
  },
})

What does this provide that Object.fromEntries(map) doesn't?

I see the advantage - it would not inherit from Object.prototype and thus would lack all the string methods on Object.prototype - but I'd want to see a lot of userland usage of this pattern as evidence that people actually want these semantics.

2 Likes

I can think of a couple off of the top of my head.

Node's querystring.parse() will return an object with a null prototype.
Things like express's urlencoder middleware uses Node's querystring.parse() under the hood, and so will also return such an object.

I'm sure there are more examples like this, but I guess I don't know that, so it would be interesting to know how much it gets used in the wild.

@claudiameadows

Object.fromEntries() will still return an object with the prototype Object.prototype, so you still have to be careful not to accidentally access inherited properties.

For example:

const mapOfActions = Object.fromEntries([
  ['action1', () => doAction1()],
  ['action2', () => doAction2()],
])

function doAction(userProvidedKey) {
  const action = mapOfActions[userProvidedKey]
  console.log(action && action())
}

Spot the bug?

What if userProvidedString was 'toString'? I'm sure you could imagine worse versions of this where javascript is running on the server, and the user-provided string created a security vulnerability. This is avoidable if the writer of this code snippet either used a Map instance, or followed the common advice to check for property existence with Object.prototype.hasOwnProperty.call(), but it's easy to forget these checks, and it's cumbersome to do them.

Now the same example with Object.createMap()

const mapOfActions = Object.createMap({
  action1: () => doAction1(),
  action2: () => doAction2(),
})

function doAction(userProvidedKey) {
  const action = mapOfActions[userProvidedKey]
  console.log(action && action())
}

Now calling doAction with 'toString' would correctly result in 'undefined' - the behavior that was supposed to happen when keys were supplied that were not in the map.

Why should it be a method and not a new class? If it would be a class, it could be created with Object.create, inherited, extended, etc. It plays well with native JS library and seems to be straightforward with current JS OOP model.

There could be RawObject, some class derived from null which has no methods and could be inherited using extend and its ancestor a MapObject. This is an example implementation of suggested behavior:

class RawObject {}

Object.setPrototypeOf(RawObject.prototype, null)

MapObject implementation:

class MapObject extends RawObject {
  static from(props) {
    return Object.assign(new this(), props)
  }

  static fromEntries(entries) {
    // ...
  }
  
  [Symbol.toStringTag]() {
    return this.constructor.name
  }

  *[Symbol.iterator]() {
    for (const key in this) {
       if (Object.prototype.hasOwnProperty.call(this, key)) {
         yield [key, this[key]]
       }
    }
  }
}

Now static constructor and Object.create works fine:

// Create with Object.create
const mapObject = Object.assign(Object.create(MapObject.prototype), {
  action1: () => "action 1",
  action2: () => "action 2",
})

// Create with static constructor
const mapObject = MapObject.from({
  action1: () => "action 1",
  action2: () => "action 2",
})

console.log(mapObject.action1()); // => "action 1"
console.log(mapObject.action2()); // => "action 2"

for (const [k, v] of mapObject) {
  console.log(k, v)
}

Such solution has several benefits:

  1. It's possible to check such map with instanceof.
  2. It could be inherited to modify the behavior, e.g. to accept only exact key names or values.
  3. RawObject could be used to implement other custom objects.
  4. RawObject can be inherited, while null can't.

I believe this is a good idea to do so. But map object could be implemented as a library. And then reimplemented as built-ins, if it there will be a need. So I'd like to see RawObject-alike built-in as a building block.

1 Like

I like the idea of using classes - there are a lot of pros to making this a class instead, as you mentioned. Plus, it makes it more consistent with how other types are handled in javascript.

There is one annoying pitfall though - the class keyword seems to automatically cause a "constructor" property to be added to the prototype.

Let's take the suggested RawObject class:

class RawObject {}
Object.setPrototypeOf(RawObject.prototype, null)

const rawObj = new RawObject()
console.log(rawObj.constructor) // [class RawObject]

Whoops! This seems to be fixable though, we can just remove the constructor property from the prototype after creating the class.

class RawObject {}
Object.setPrototypeOf(RawObject.prototype, null)
delete RawObject.prototype.constructor

const rawObj = new RawObject()
console.log(rawObj.constructor) // undefined

That's better! Now let's try to subclass it:

class RawObject {}
Object.setPrototypeOf(RawObject.prototype, null)
delete RawObject.prototype.constructor

class CustomClass extends RawObject {}

const customObj = new CustomClass()
console.log(customObj.constructor) // [class CustomClass extends RawObject]

Darn! Each subclass also has to explicitly delete the constructor property off of its prototype. I don't like the idea of having to make users of this class remember to delete the constructor property when they wish to subclass it.

I can't think of any good way around this issue. There might be, so please share if anyone can think of anything, but maybe the best solution would be to just not define this with an es6 class, making those who wish to subclass it have to do so with the lower-level prototype functions, such as Object.create()

Yep, this is a good point. I think you've highlighted a bigger JS problem, which should be solved instead of making myriads of tiny patches.

I'm sure such map could be implemented as a standalone library. And if it will be popular, it will receive support from community and then it should be adopted to the standard. Maybe there are some. Have you looked at NPM?

You know, I just had a change of heart with this proposal.

I initially made this proposal in part because of this section on mdn explaining some of the pitfalls of using Object.create(null). I had realized that a few of those pitfalls were fixable with the help of Symbol.toPrimitive, and the rest were basically telling you that things like ".toString()" won't be found on the object - which is the whole point.

This Object.createMap() proposal only provided marginal improvements over Object.create(null) - the biggest one is the fact that it can be converted to a (useless) primitive, making code that expects this ability to not break (i.e. some sort of not-so-well-designed logging function or something). I knew that these improvements were minor enough that it could potentially be decided that it wasn't worth it to create a function just for this, but I thought the discussion was worth having to see what other people's thoughts were on the matter.

I have recently realized that symbols were purposely implemented to have the exact same issue I'm trying to avoid with null-prototypes - they also throw an error when they get converted to a number or string. No one seems to be complaining about that, and everyone's codebases continue to work.

So, thanks everyone for the feedback.

I'm now against this proposal too.

2 Likes