-1

I want to define some object so that every property provides an empty array if it's not defined.

Example: consider the list of users, every user is {id: number, age: string, ...}. We want to group users by age:

const users = [{id: 1, age: 42}, {id: 2, age: 42}, {id: 3, age: 43}]

const usersByAge = users.reduce((out, user) => {
  if (!out.hasOwnProperty(user.age)) {
    out[user.age] = []
  }
  out[user.age].push(user)
  return out
}, {})

console.log(usersByAge[42]) -> [{id: 1, age: 42}, {id: 2, age: 42}]
console.log(usersByAge[43]) -> [{id: 3, age: 43}]
console.log(usersByAge[18]) -> undefined
console.log(Object.keys(usersByAge)) -> ['42', '43']

This code works, but I want to shorten it by using special JavaScript Proxy. It should return an empty array if the property is not defined. It should be possible to push items into the property even if it's not defined. It should provide the same array for different undefined properties:

const getMapFromIdToArray = () => {
  return new Proxy({}, {
    get: (target, name) => {
      if (!target.hasOwnProperty(name)) {
        target[name] = []
      }
      return target[name]
    }
  })
}

const usersByAge = users.reduce((out, user) => {
  out[user.age].push(user) // <- this should work, array initialization should not be required
  return out
}, getMapFromIdToArray())

console.log(usersByAge[42]) -> [{id: 1, age: 42}, {id: 2, age: 42}] - OK
console.log(usersByAge[18]) -> [] - OK
console.log(usersByAge[18] === usersByAge[19]) -> true - FAIL
console.log(Object.keys(usersByAge)) -> ['42', '43'] - FAIL

This code works partly. If the property is not defined, it's initialized as []. So the getter actually mutates the target. I would like to avoid this. But the same time I want to be able to call .push on an undefined property without initialization. Is it possible to achieve?

Kasheftin
  • 7,509
  • 11
  • 41
  • 68
  • is this what you get when you run your code `[ { id: 1, age: 42 }, { id: 2, age: 42 } ] [] false [ '18', '19', '42', '43' ]` – Mohammed naji Feb 09 '22 at 17:51
  • `It should be possible to push items into the property even if it's not defined.` are you sure that's sensible? Consider `usersByAge[17].push({id: 6, age: 24});` Also, if you do this, your `users` array remains untouched. I'd suggest just pushing to the users array and then re-running the reduce. – James Feb 09 '22 at 18:18

2 Answers2

2

It should provide the same array for different undefined properties

I'm not sure what you meant in this sentence, but you definitely don't want two different properties to return reference to the same array and then items belonging to different properties being pushed to that array.

If the property is not defined, it's initialized as []. So the getter actually mutates the target. I would like to avoid this. But the same time I want to be able to call .push on an undefined property without initialization. Is it possible to achieve?

I would say yes and no. The push method has to work on an actual array, and the object must hold a reference to that array in order to retrive the pushed items later. But if you don't want the empty array to create an actual property in the target object right away, then you can store it separately in the proxy until it requested after being mutated:

new Proxy({}, {
  tempProps: {},
  get: (target, name, reciever) => {
    if (target.hasOwnProperty(name)) {
      return target[name];
    }

    if (tempProps.hasOwnProperty(name)) {
      if tempProps[name].length {
        // array is not empty - bring into target
        target[name] = tempProps[name];
        delete tempProps[name];
        return target[name];
      } else {
        return tempProps[name];
      }
    }

    tempProps[name] = [];
    return tempProps[name];
  }
})

It may also be possible to hold the reference to the returned empty array in a WeakRef object, so that if the caller drops all references to the array it will finally be garbage collected. But that seems unnecessarily complicated unless in very specific circumstances.

Noam
  • 1,317
  • 5
  • 16
  • Thank you, this is an interesting approach. About the same reference to an empty array.. it still can be useful. The real use case is that these maps are used in vuex getters, they can be rebuilt but they can not be updated (it's like reactive read-only object, if it needs to be updated, it's recreated from scratch). Then we can return the same frozen empty array if the property is not defined. It gives a small performance improvement in vue because of how reactivity works there. I made a small test using 2 proxies, https://jsfiddle.net/kasheftin/ap5kwd4n/1/, WDYT? – Kasheftin Feb 10 '22 at 10:19
  • I have no experience with Vue syntax, but if the returned array is frozen then sure you can have only one instance of the empty array. But that contradicts the sentence *"It should be possible to push items into the property even if it's not defined"* as you cannot push items into a frozen array. – Noam Feb 10 '22 at 10:48
  • Yes, you are correct. Now I understand that the requirements are not compatible. There're 2 stages. One the first, generating stage, it should be easy to add items, and of course this time the items should not use one shared empty array reference. Then it goes to the read stage, and there it can return single the-same-reference empty frozen array. And we can use Object.isFrozen to detect the stage, https://jsfiddle.net/kasheftin/yzLpuq0g/. Thanks a lot! – Kasheftin Feb 10 '22 at 12:37
0

I would like to post a solution here we came to after several iterations. The solution works fine when it's used in vuex getter. It also freezes the result (getters are read-only), the resulting array is not extendable.

type MapFromIdTo<T> = Record<string, T>

const emptyArray = Object.freeze(new Array<never>())

export function getMapFromIdToArray<T, P = T>(entries: T[], reducer: (out: MapFromIdTo<P[]>, entry: T) => void): MapFromIdTo<P[]> {
  const extendableTarget: MapFromIdTo<P[]> = new Proxy({}, {
    get: (target: MapFromIdTo<P[]>, name: string) => {
      if (!target[name] && !Object.isFrozen(target)) {
        target[name] = []
      }
      return target[name] || emptyArray
    }
  })

  entries.forEach((entry) => {
    reducer(extendableTarget, entry)
  })

  Object.freeze(extendableTarget)
  Object.values(extendableTarget).forEach((keyEntries) => {
    Object.freeze(keyEntries)
  })

  return extendableTarget
}

In vuex store it should be used like that:

const state = () => ({
  users: [{id: 1, age: 42}, {id: 2, age: 42}, {id: 3, age: 43}]
})

const getters = {
  usersByAge: state => getMapFromIdToArray<User, User>(state.users, (out, user) => {
    out[user.age].push(user)
  })
}
Kasheftin
  • 7,509
  • 11
  • 41
  • 68