logo

Destructuring... with object or array?

Oct 21, 2020·8min
Destructuring... with object or array?

Destructuring is a JavaScript language feature introduced in ES6 which I would assume you already familiar with it before moving on.

We see it quite useful in many scenarios, for example, value swapping, named arguments, objects shallow merging, array slicing, etc. Today I would like to share some of my immature thoughts on "destructuring" in some web frameworks.

I am a Vue enthusiast for sure and I wrote a lot of my apps using it. And I did write React a while for my previous company reluctantly. As the Vue 3.0 came out recently, its exciting Composition API provides quite similar abilities for abstracting. Inspired by react-use, I wrote a composable utility collection library early this year called VueUse.

Similar to React hooks, Vue's composable functions will take some arguments and returns some data and functions. JavaScript is just like other C-liked programming languages - only one return value is allowed. So a workaround for returning multiple values, we would commonly wrap them with an array or an object, and then destructure the returned arrays/objects. As you can already see, we are having two different philosophies here, using arrays or objects.

Destructuring Arrays / Tuples

In React hooks, it's a common practice to use array destructuring. For example, built-in functions:

const [counter, setCounter] = useState(0)

Libraries for React hooks would natural pick the similar philosophy, for example react-use:

const [on, toggle] = useToggle(true)
const [value, setValue, remove] = useLocalStorage('my-key', 'foo')

The benefits of array destructuring is quite straightforward - you get the freedom to set the variable names with the clean looking.

Destructuring Objects

Instead of returning the getter and setter in React's useState, in Vue 3, a ref is created combining the getter and setter inside the single object. Naming is simpler and destructuring is no longer needed.

// React
const [counter, setCounter] = useState(0)
console.log(counter)        // get
setCounter(counter + 1)     // set

// Vue 3
const counter = ref(0)
console.log(counter.value)  // get
counter.value++             // set

Since we don't need to rename the same thing twice for getter and setter like React does, in VueUse, I implemented most of the functions with object returns, like:

const { x, y } = useMouse()

Using objects gives users more flexibility like

// no destructing, clear namespace
const mouse = useMouse()

mouse.x
// use only part of the value
const { y } = useMouse()
// rename things
const { x: mouseX, y: mouseY } = useMouse()

While it's been good for different preferences and named attributes can be self-explaining, the renaming could be somehow verbose than array destructuring.

Support Both

What if we could support them both? Taking the advantages on each side and let users decide which style to be used to better fit their needs.

I did saw one library supports such usage once but I can't recall which. However, this idea buried in mind since then. And now I am going to experiment it out.

My assumption is that it returns an object with both behaviors of array and object. The path is clear, either to make an object like array or an array like object.

Make an object behaves like an array

The first possible solution comes up to my mind is to make an object behaves like an array, as you probably know, arrays are actually objects with number indexes and some prototypes. So the code would be like:

const data = {
  foo: 'foo',
  bar: 'bar',
  0: 'foo',
  1: 'bar',
}

let { foo, bar } = data
let [ foo, bar ] = data // ERROR!

But when we destructure it as an array, it will throw out this error:

Uncaught TypeError: data is not iterable

Before we working on how to make an object iterable, let's try the other direction first.

Make an array behaves like an object

Since arrays are objects, we should be able to extend it, like

const data = ['foo', 'bar']
data.foo = 'foo'
data.bar = 'bar'

let [ foo, bar ] = data
let { foo, bar } = data

This works and we can call it a day now! However, if you are a perfectionist, you will find there is an edge case not be well covered. If we use the rest pattern to retrieve the remaining parts, the number indexes will unexpectedly be included in the rest object.

let { foo, ...rest } = data

rest will be:

{
  bar: 'bar',
  0: 'foo',
  1: 'bar'
}

Iterable Object

Let's go back to our first approach to see if we can make an object iterable. And luckily, Symbol.iterator is designed for the task! The document shows exactly the usage, doing some modification and we get this:

const data = {
  foo: 'foo',
  bar: 'bar',
  *[Symbol.iterator]() {
    yield 'foo'
    yield 'bar'
  },
}

let { foo, bar } = data
let [ foo, bar ] = data

It works well but the Symbol.iterator will still be included in the rest pattern.

let { foo, ...rest } = data

// rest
{
  bar: 'bar',
  Symbol(Symbol.iterator): ƒ*
}

Since we are working on objects, it shouldn't be hard to make some properties not enumerable. By using Object.defineProperty with enumerable: false:

const data = {
  foo: 'foo',
  bar: 'bar',
}

Object.defineProperty(data, Symbol.iterator, {
  enumerable: false,
  value: function*() {
    yield 'foo'
    yield 'bar'
  },
})

Now we are successfully hiding the extra properties!

let { foo, ...rest } = data

// rest
{
  bar: 'bar'
}

Generator

If you don't like the usage of generators, we can implement it with pure functions, following this article.

Object.defineProperty(clone, Symbol.iterator, {
  enumerable: false,
  value() {
    let index = 0
    let arr = [foo, bar]
    return {
      next: () => ({
        value: arr[index++],
        done: index > arr.length,
      })
    }
  }
})

TypeScript

To me, it's meaningless if we could not get proper TypeScript support on this. Surprisingly, TypeScript support such usage almost out-of-box. Just simply use the & operator to make insertion of the object and array type. Destructuring will properly infer the types in both usages.

type Magic = { foo: string, bar: string } & [ string, string ]

Take Away

Finally, I made it a general function to merge arrays and objects intro the isomorphic destructurable. You can just copy the TypeScript snippet below to use it. Thanks for reading through!

Please note this does NOT support IE11. More details: Supported browers

function createIsomorphicDestructurable<
  T extends Record<string, unknown>,
  A extends readonly any[]
>(obj: T, arr: A): T & A {

  const clone = { ...obj }

  Object.defineProperty(clone, Symbol.iterator, {
    enumerable: false,
    value() {
      let index = 0
      return {
        next: () => ({
          value: arr[index++],
          done: index > arr.length,
        })
      }
    }
  })

  return clone as T & A
}

Usage

const foo = { name: 'foo' }
const bar: number = 1024

const obj = createIsomorphicDestructurable(
  { foo, bar } as const,
  [ foo, bar ] as const
)

let { foo, bar } = obj
let [ foo, bar ] = obj