13

I am trying to figure out if it is possible to handle multiple levels of default parameters with destructuring. Since it is not easy to explain with words, here is a step-by-step example...


1 - Flat object destructuring with default parameters

Destructuring this object is easy:

let obj = {
  foo: 'Foo',
  bar: 'Bar'
};

With {foo = 'Foo', bar = 'Bar'} = {} in a function signature, an object will be created if there is no argument passed when the function is called. If an object is passed but some referenced properties are undefined, they will be replaced by their default values. This code works fine:

function fn1({foo = 'Foo', bar = 'Bar'} = {}) {
  console.log(foo, bar);
}

// OK
fn1(); // Foo Bar
fn1({foo: 'Quux'}); // Quux Bar
fn1({bar: 'Quux'}); // Foo Quux
fn1({foo: 'Quux', bar: 'Quux'}); // Quux Quux

2 - Nested object destructuring with shallow default parameters

Destructuring this object is harder:

let obj = {
  foo: 'Foo',
  bar: {
    quux: 'Quux',
    corge: 'Corge'
  }
};

{foo = 'Foo', bar = 'Bar'} = {} is not a viable option anymore, but now we can use {foo = 'Foo', bar = {quux: 'Quux', corge: 'Corge'}} = {} in a function signature. Again, if no argument is given when the function is called, an object is created and the core properties (foo and bar) are extracted. If an object is passed, only undefined properties (foo or bar) will be destructured with their default values.

The problem is that the object properties of bar (quux and corge) are not part of the "top-level destructuring". This means quux or corge will be undefined if they are not explicitly set when bar is passed as an argument:

function fn2({foo = 'Foo', bar = {quux: 'Quux', corge: 'Corge'}} = {}) {
  console.log(foo, bar.quux, bar.corge);
}

// OK
fn2(); // Foo Quux Corge
fn2({foo: 'Quux'}); // Quux Quux Corge

// Oops!
fn2({bar: {quux: 'Baz'}}); // Foo Baz undefined
fn2({foo: 'Quux', bar: {corge: 'Baz'}}); // Quux undefined Baz

3 - Nested object destructuring with deep default parameters

I would like to set default parameters at all levels of the object hierarchy to use a sort of "cascading destructuring". I tried this, but it does not work:

function fn3({foo = 'Foo', bar = ({quux = 'Quux', corge = 'Corge'} = {})} = {}) {
  console.log(foo, bar.quux, bar.corge);
}

// Oops!
fn3(); // Foo undefined undefined
fn3({foo: 'Quux'}); // Quux undefined undefined
fn3({bar: {quux: 'Baz'}}); // Foo Baz undefined
fn3({foo: 'Quux', bar: {corge: 'Baz'}}); // Quux undefined Baz

Do you know if such a feature is allowed in ES6. If yes, how can I implement it?

Badacadabra
  • 8,043
  • 7
  • 28
  • 49
  • 1
    You should use prototyping for this. Not only will it make the construction easier, it will perform better in memory. – Travis J May 10 '17 at 20:03
  • To be honest, I would not use this in everyday coding because it is hard to read and probably memory-intensive. It is just an experiment with ES6. I try to test its limits. :) – Badacadabra May 10 '17 at 20:09
  • And how to combine this with aliases? – dude Jun 07 '18 at 17:34

3 Answers3

11

The generic pattern for destructuring object properties is

{ … , propertyName: target = defaultInitialiser, … }

(when the property name is exactly the same as the target variable identifier we can join them).

But target is not only for variables, it can be any assignment target - including nested destructuring expressions. So for your case (3) you want to use exactly the same approach as with (1) on the top level of the parameter - default initialise the property with an empty object and destructure its parts:

function fn3({foo = 'Foo', bar: {quux = 'Quux', corge = 'Corge'} = {}} = {}) {
  console.log(foo, quux, corge);
}

Notice that there is no bar variable when you destructure that property. If you want to introduce a bar variable for the property as well, you could repeat the property name and do

function fn3({foo = 'Foo', bar, bar: {quux = 'Quux', corge = 'Corge'} = {}} = {}) {
  console.log(foo, bar, quux, corge);
}
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • 1
    OK. My conclusion is that we cannot destructure `bar` **and** its properties with default values. The best thing we can do is destructuring `bar` parts, so there is no "cascading destructuring" in ES6... Thanks for your answer. :) – Badacadabra May 11 '17 at 15:01
  • 1
    @Badacadabra Actually it's quite possible. But usually it'll be much more readable when you just put multiple destructuring statements in the function body. – Bergi May 11 '17 at 15:53
  • Interesting edit... The problem I can see in this proposition is that you will get `undefined` for `bar` with the following calls: `fn3()` or `fn3({foo: 'Quux'})`. You will also get partial `bar` with the following calls: `fn3({bar: {quux: 'Baz'}})` or `{foo: 'Quux', bar: {corge: 'Baz'}}`. In any case, I agree: this code is hard to read. – Badacadabra May 11 '17 at 16:06
  • 1
    Yes, of course you would get the actual value for `bar`. If you want an object that consists of `quux` and `corge` with their default values, you need to build that explicitly: `const bar = {quux, corge}`. That's not what destructuring was made for. – Bergi May 11 '17 at 16:10
  • 1
    As I said in a previous comment, this is mostly an experiment with ES6... I would not use such a complex thing in a standard project. Destructuring is a powerful feature, but there are not so many use cases for unreadable nested default parameters like in this question. But I think I have a better understanding of the limits now and this matters. :) – Badacadabra May 11 '17 at 16:18
1

I've got something that's a little simpler. It has drawbacks. But first, the goods:

function doit( arg1 = 'one', hash = { prop1: 'two', prop2: 'three' }, { prop1, prop2 } = hash ) {
    console.log(`arg1`, arg1)
    console.log(`prop1`, prop1)
    console.log(`prop2`, prop2)
    console.log(`hash`, hash)
}

What does this accomplish?

  • provides names for all positional arguments, including the hash of named arguments
  • destructures all named arguments
  • provides a default value for every argument, whether positional or named

How does it work?

ES6 parameter defaults can refer to other parameters, like so:

function math( x = 1, y = x ) { ... }
// x = 1
// y = 1

So, even though the example function is designed to accept two arguments (arg1 and hash), the signature is formally declared with three arguments. The third argument is a kind of fictional or temporary argument that exists solely for the purpose of destructuring hash. It is the logical equivalent of this:

function doit( arg1 = 'one', hash = { prop1: 'two', prop2: 'three' } ) {
    let { prop1, prop2 } = hash
    ...
}

The virtue of this pattern is that the signature is completely self-documenting. It's sadly very common in JS to see signatures declared like this:

function terminateEmployee( employeeId, options ) {
    // what properties does `options` accept??
}

To answer that question, you need to search all downstream codepaths and collect every use of options. Sometimes that codepath is really long; if you're unlucky enough to be working in a microservice-based ecosystem, that codepath can span two or more additional codebases in other languages (true story).

Yes, we can ask devs to write documentation, but YMMV on that score.

So, this pattern allows the implementation to be self-documenting, without relying on extra documentation that devs would need to write and maintain.

The downside is that the function looks like it accepts three arguments -- and it really does accept three. So, devs who are unaware of what's going on can be misled. And, if a caller passes three args, the third arg will override the second arg.

Tom
  • 8,509
  • 7
  • 49
  • 78
-1

what about

function fn3({foo = 'Foo', bar={} } = {}) {
   const {quux = 'Quux', corge = 'Corge'} = bar;
    console.log(foo, quux, corge);
}


fn3(); // Foo Quux Corge
fn3({foo: 'Quux'}); // Quux Quux Corge
fn3({bar: {quux: 'Baz'}}); // Foo Baz Corge
fn3({foo: 'Quux', bar: {corge: 'Baz'}}); // Quux Quux Baz