2

I am working with an object where I need to preserve the order of the entries, even though some keys are alphanumeric and others are integers. (Yes, I know.)

The object I'm starting with looks like this:

{
  a: 'the',
  quick: 'quick',
  b: 'brown',
  fox: 'fox'
}

After manipulation, the object should look like this:

{
  a: 'the',
  0: 'quick',
  b: 'brown',
  1: 'fox'
}

But. Because iteration order in javascript objects differs from insertion order (integers are iterated first), if I go about this straightforwardly, I won't get the correctly ordered result:

let myReindexedObject = {};

myReindexedObject['a'] = 'the';
myReindexedObject['0'] = 'quick';
myReindexedObject['b'] = 'brown';
myReindexedObject['1'] = 'fox';

console.log(myReindexedObject);

I've tried to solve this issue by building a Map (which, unlike an object, preserves entry order) which I can then convert into an object.

Source: (I adapted this gist by Luke Horvat: Convert ES6 Map to Object Literal .)

Can you guess what happens?

let myMap = new Map();

myMap.set('a', 'the');
myMap.set('0', 'quick');
myMap.set('b', 'brown');
myMap.set('1', 'fox');

let myArray = Array.from(myMap);

let myReindexedObject = myArray.reduce((myReindexingObject, [key, value]) => {
  return Object.assign(myReindexingObject, { [key]: value })
}, {});

console.log(myReindexedObject);

Is there any way I can use integer-based keys like 0 and 1 and still preserve the object entries in a custom order?

Or do I need to consider other approaches?

Rounin
  • 27,134
  • 9
  • 83
  • 108
  • 6
    No. Object property ordering is defined (now) in the language spec, but it is immutable. Numeric entries come first, other entries come in the order in which the properties were added. Relying on object property ordering in JavaScript is a really bad idea, because it makes code extremely fragile. If you need an ordering, create an array with the properties in it in the order that works for your application. – Pointy Nov 12 '21 at 17:18
  • 1
    Why can't you use Map directly? Instead of cycling through `Object.keys(myObject)` why not use `myMap.keys()` or `[...myMap.keys()]`? – the Hutt Jan 27 '22 at 17:14
  • @onkarruikar - Ideally, I want to be working with an `Object` at all times and I don;t want to involve `Maps` at all. Amongst other things, browser consoles don't like `Maps` - if you `console.log` a `Map` you just get `{}` - and it's not possible to directly _stringify_ a `Map` into `JSON`. – Rounin Jan 28 '22 at 10:27

2 Answers2

3

In the process of writing the question above, it suddenly occurred to me as I was typing:

(integers are iterated first)

that what a javascript engine recognises as an integer and what humans recognise as a number are, of course, not the same.

To any human, these two:

1

1.

are not typographically identical, but they are pretty much equivalent.

To any javascript interpreter, they are entirely distinct: the first is an integer; the second is not.


Working Example:

let myReindexedObject = {};

myReindexedObject['a'] = 'the';
myReindexedObject['0.'] = 'quick';
myReindexedObject['b'] = 'brown';
myReindexedObject['1.'] = 'fox';

console.log(myReindexedObject);

If the javascript interpreter needs to identify these indexes, it can do so, using the regex:

/d+\./

and, once identified, if it needs to know the integer that the string corresponds to, it can use:

parseInt(myIndex);

I will use this approach for now.

If anyone can suggest a better approach, I will be happy to upvote and accept.

Rounin
  • 27,134
  • 9
  • 83
  • 108
  • 2
    Note that `parseInt("1.")` will successfully return `1`, which may or may not be what you want. – Pointy Nov 12 '21 at 18:54
  • Yes, that's fine. While cycling through `Object.keys(myObject)`, I can do something like: `let thisKey = (myKeys[i].match(/d+\./)) ? parseInt(myKeys[i]) : myKeys[i];` (or the `.forEach()` / `.map()` equivalent). – Rounin Nov 12 '21 at 20:47
  • 2
    @Rounin I added a bounty for this post. Lets see if we can get any better answer of your problem. – Debug Diva Jan 27 '22 at 13:53
  • Much obliged, @CreativeLearner - thank you! – Rounin Jan 28 '22 at 10:19
  • I'm deeply disturbed that JavaScript to this day still hasn't figured out that if I wanted my "index-integers" ordered by value, I'd use something known as an "array" as opposed to an "object". The fact that they've left us no option to maintain insertion-order for object keys is extremely short-sighted and ignorant! Take a hint from PHP on this one already! Having to resort to hacks like this one to actually be able to send the object to the server is infuriating! – Fuad Zeyad Tareq Jul 14 '23 at 17:26
  • I hear you, @FuadZeyadTareq. It's worth noting that modern JS also has the structure `Map` (see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), which, unlike `Object`, is *ordered-by-insertion* and *iterable*. **But...** to date, `Map` doesn't play nicely with `JSON` or even with `console.log`. – Rounin Jul 18 '23 at 20:07
3

We can define our own object, that keeps track of properties. And by intercepting required features we can make it work.
Using Proxy it's easily achievable:

// DEMO
let o = new CoolObject();
o['a'] = 'the';
o['0'] = 'quick';
o['b'] = 'brown';
o['1'] = 'fox';
o['c'] = 'jumped';
delete o['c'];

console.log('Object.keys: ', Object.keys(o));
console.log('JSON.stringify: ', JSON.stringify(o));
console.log('console.log: ', o);
console.log('Object.getOwnPropertyNames: ', Object.getOwnPropertyNames(o));
console.log('obj.propertyIsEnumerable("keys"): ', o.propertyIsEnumerable('keys'));
console.log('obj.propertyIsEnumerable("a"): ', o.propertyIsEnumerable('a'));
<script src="https://cdn.jsdelivr.net/gh/OnkarRuikar/temp@main/CoolObject.js"></script>
See console logs for output.

Note the insertion ordered property names. Result of getOwnPropertyNames are also insertion ordered except methods.


The CoolObject class definition:

(function () {
      // original functions
      let _keys = Object.keys;
      let _getOwnPropertyNames = Object.getOwnPropertyNames;
      let _defineProperty = Object.defineProperty;
      let _stringify = JSON.stringify;
      let _log = console.log;

      // main feature definition
      let CoolObject = function () {
        let self = this;
        let handler = {
          _coolKeys: [],

          set(target, key, val) {
            let keys = this._coolKeys;
            if (!keys.some(k => k === key))
              keys.push(key);

            target[key] = val;
          },

          get(target, key) {
            return target[key];
          },

          keys() {
            return this._coolKeys.slice(0);
          },

          deleteProperty(target, key) {
            let keys = this._coolKeys;
            const index = keys.indexOf(key);
            if (index > -1) {
              keys.splice(index, 1);
            }

            delete target[key];
          },

          defineProperty(obj, prop, val) {
            let keys = this._coolKeys;
            if (!keys.some(k => k === prop))
              keys.push(prop);
            _defineProperty(self, prop, val);
          },

          getOwnPropertyNames(obj) {
            let props = _getOwnPropertyNames(obj);
            return [...new Set([...this._coolKeys, ...props])];
          },

          // many improvements can be done here
          // you can use your own modified pollyfill
          stringifyHelper(obj, replacer, space) {
            let out = '{';
            for (let key of this._coolKeys) {
              out += `"${key}":${_stringify(obj[key], replacer, space)}, `;
            }
            out += '}';
            return out;
          },

        };

        _defineProperty(self, 'keys', { value: () => handler.keys() });
        _defineProperty(self, 'getOwnPropertyNames', { value: (o) => handler.getOwnPropertyNames(o) });
        _defineProperty(self, 'stringify', { value: (...args) => handler.stringifyHelper(...args) });

        return new Proxy(self, handler);
      } // CoolObject end


      // ----- wrap inbuilt objects -----
      Object.keys = function (obj) {
        if (!(obj instanceof CoolObject))
          return _keys(obj);
        return obj.keys();
      }

      Object.defineProperty = function (obj, prop, val) {
        if (!(obj instanceof CoolObject))
          _defineProperty(...arguments);
        obj.defineProperty(...arguments);
      }

      Object.getOwnPropertyNames = function (obj) {
        if (!(obj instanceof CoolObject))
          return _getOwnPropertyNames(obj);
        return obj.getOwnPropertyNames(obj);
      }

      JSON.stringify = function (obj, replacer, indent) {
        if (!(obj instanceof CoolObject))
          return _stringify(...arguments);
        return obj.stringify(...arguments);
      }

      console.log = function () {
        let myArgs = [];
        for (let arg of arguments) {

          if (arg instanceof CoolObject) {
            let keys = arg.keys();
            arg = Object.assign({}, arg);
            for (let key of keys) {
              arg[`.${key}`] = arg[key]
              delete arg[key];
            }
          }

          myArgs.push(arg);
        }
        _log(...myArgs);
      }

      window.CoolObject = CoolObject;
    })();

The handler object maintains property names in _coolKeys array. And tracks addition and deletion operations. To make object behave like an original Object we need to wrap some inbuilt APIs, like Object.keys().


Note: for the demo I've implemented bare minimum rough code. Many improvements can be done. You can intercept more inbuilt APIs as per your requirements.

the Hutt
  • 16,980
  • 2
  • 14
  • 44