Search code examples
javascriptecmascript-nextes6-modules

How to override object destructuring for ES class just like array destructuring


TL;DR

How do I make {...myCls} return {...myCls.instanceVar}?

Actual Question

I'm trying to implement a custom version of *[Symbol.iterator]() { yield this.internalObj; } such that object-spreads of my class perform an object-spread operation to myClass.instanceVar. Specifically, I want to make {...(new MyClass('a', 'b'}))} return {...(new MyClass('a', 'b'})).innerVal}. However, it seems we cannot override object-spread logic, we can only override array-spread logic.

For example, this is a simple class to create an Array wrapper

class MyArray {
    innerArray = [];

    getItem(index) {
        return (index < this.innerArray.length) ? this.innerArray[index] : null;
    }

    setItem(index, val) {
        const innerArrayLength = this.innerArray.length;

        return (index < innerArrayLength)
            ?
                this.innerArray[index] = val
            :
                this.innerArray.push(val)
        ;
    }

    removeItem(index) {
        return this.innerArray.splice(index, 1);
    }

    clear() {
        this.innerArray = [];
    }

    get length() {
        return this.innerArray.length;
    }

    *[Symbol.iterator]() {
        return yield* this.innerArray;
    }
}

// Usage:
let myArr = new MyArray()   // undefined
myArr.setItem(0, 'a')   // 1
myArr.setItem(10, 'b')   // 2
console.log([...myArr])   // (2) [ 0 => "a", 1 => "b" ]

However, what I want is a way to do that with object class instance variables instead of array class instance variables.

For example, this is what happens when I try to implement a StorageMock class

class StorageMock {
    storage = {};

    setItem(key, val) {
        this.storage[key] = val;
    }

    getItem(key) {
        return (key in this.storage) ? this.storage[key] : null;
    }

    removeItem(key) {
        delete this.storage[key];
    }

    clear() {
        this.storage = {};
    }

    get length() {
        return Object.keys(this.storage).length;
    }

    key(index) {
        return Object.keys(this.storage)[index] || null;
    }

    *[Symbol.iterator]() {
        return yield* Object.entries(this.storage).map(([ k, v ]) => ({ [k]: v }));
    }
}

let myStore = new StorageMock()    // undefined
myStore.setItem('a', 'hello');    // undefined
myStore.setItem('b', 'world');    // undefined
console.log({...myStore});   // { storage: { a: "hello", b: "world" } }  <== PROBLEM

// Doing the same with localStorage prints out:
// { a: "hello", b: "world" }
// instead of
// { storage: { a: "hello", b: "world" } }

In this case, the Storage API works to spread storage entries when spreading (local|session)Storage, but creating a special StorageMock class does not.

Point being that I can't make {...storageMockInstance} === {...(storageMockInstance.storage)}. So how does one override the object-spreading syntax of an ES class?

References/attempts

I've tried various combinations of Object.create(), Object.definePropert(y|ies)(), variants of the in operator (all of which have relevant access-ability defined here), all depending on the for...in syntax defininition from the generic-spreading-syntax proposal. But all I've found is that only "standard" destructuring can be used according to references 1, 2, and 3.

But there has to be a way to do this via ESNext classes. None of my attempts to accomplish the ability to use actual native class features instead of those available through AMD module syntax. It doesn't seem reasonable that I couldn't override these fields in a way similar to how other languages do so. i.e. If I could only override how the JS for..in loop works in the same way that Python allows overriding it, then I could spread the inner variable through a forIn() method just like toString() or toJSON().

Note

Please do not respond with @babel/polyfill, core-js, or babel-jest for this question. It's not only meant for (local|session)Storage, but also just a question on a high-level problem.


Solution

  • Short answer

    You cannot.

    Unless you cheat. But might not be worth it.

    Actual answer

    The term "array destructuring" might be a slightly misleading. It actually always starts by getting the iterator of the object and draws values from there until all bindings are satisfied. In fact, it is not only supposed to be used on arrays.

    const obj = { 
      *[Symbol.iterator]() { 
        yield 1; 
        yield 2; 
        yield 3; 
        yield 4; 
      } 
    };
    
    //1. take iterator
    //2. take first three values
    const [a, b, c] = obj;
    //1. take iterator (do not continue the old one)
    //2. take the first value 
    const [x] = obj;
    
    console.log(a, b, c, x); // 1, 2, 3, 1

    Object destructuring, however, does not have a similar mechanism. When using {...x} the abstract operation CopyDataProperties is performed. As the name suggests, it will copy properties, rather than invoke some mechanism to get the data to copy.

    The properties that will be copied would be

    1. Own - not coming from the prototype.
    2. A data properties as opposed to an accessor properties (a property defined by a getter and/or setter).
    3. Enumerable.
    4. Not part of the excludedNames parameter to the abstract operation. Note: this is only relevant when using spread with a rest target, like const {foo, bar, ...rest} = obj;

    What could be done is to lie to the runtime about each of these things. This can be done using a Proxy and you need to change the following things:

    1. ownKeys trap to switch what keys would be used for the destructuring.
    2. getOwnPropertyDescriptor trap to make sure the properties are enumerable.
    3. get trap to give the value for the property.

    This can be done as a function like this, for example and will make an object behave as if you are using one of its property values:

    const obj = {
      a: 1,  
      b: 2, 
      c: 3, 
      myProp: {
        d: 4, 
        e: 5, 
        f: 6
      }
    };
    
    const x = { ...lieAboutSpread("myProp", obj) };
    console.log(x);
    
    function lieAboutSpread(prop, obj) {
      //this will be the false target for spreading
      const newTarget = obj[prop];
      
      const handler = {
        // return the false target's keys
        ownKeys() {
          return Reflect.ownKeys(newTarget);
        },
        // return the false target's property descriptors
        getOwnPropertyDescriptor(target, property) {
          return Reflect.getOwnPropertyDescriptor(newTarget, property);
        },
        // return the false target's values
        get(target, property, receiver) { 
          return Reflect.get(newTarget, property, receiver);
        }
      }
      
      return new Proxy(obj, handler);
    }

    So, this is possible. I am however, not sure it is of that much benefit to simply doing { ...obj.myProp }. Moreover, the above function could be re-written in a way that does not "lie" at all. But it becomes extremely boring:

    const obj = {
      a: 1,  
      b: 2, 
      c: 3, 
      myProp: {
        d: 4, 
        e: 5, 
        f: 6
      }
    };
    
    const x = { ...lieAboutSpread("myProp", obj) };
    console.log(x);
    
    function lieAboutSpread(prop, obj) {
      //this will be the target for spreading
      return obj[prop];
    }

    In my opinion, this highlights why the artificial way of masking the object is an overkill.