Conditionally add elements to declaratively defined arrays

Hi all! I had an idea for a feature that I would be a really great addition to the language, and was curious on others' thoughts.

As declarative Javascript is used more and more, there is no easy way to conditionally add an element to an array in a declarative way.

I am proposing an "empty spread" operator, that allows one to return a "no elements" expression in a syntax similar to the current spread operator. Here's an example:

// current ways
const pets = [
  "Dogs",
  "Hamster",
  "Penguin",
  ...(hasCats ? ["Cats"] : []),
];

let pets = ["horse];
if (horses) {
  pets.push(horses);
}

// new way
const pets = [
  "Dogs",
  "Hamster",
  "Penguin",
  hasCats ? "Cats" : ...,
];

pets = ["horse", cow ?? ...];

See here for more examples. It is currently possible but really hard to read as a human.

I believe that an easy way to conditionally declaratively add something to an array or map would be great for reducing bugs and code complexity from two fronts. First, it would allow more code to be written declaratively and immutably. Second, it would make declarative code easier to read in situations like this.

This operator would have the same psudo-LHS characteristics as the normal spread operator - it cannot be assigned to a variable, nor returned by a function. I believe that JavaScript already has enough types to represent a value lacking some sort of trait (null, undefined), and making this its own type would add complexity to code. These operators should only be allowed to be defined within literal array and object field blocks.

A corollary is doing the same for objects. While in a lot of code patterns undefined in an object lookup is seen as there being no key, the in keyword and hasOwnProperty function actually return true if there is a key set to undefined in an object.

Syntax Possibilities I've thought of:

… - Most compact solution with spread operator
(…) - Wider empty expression with spread operator with parenthesis providing separation from potential camoflauged commas
[…], {…} - Case specific operators, with relations to expression, potentially confusing
() - Signifies an empty expression, also potentially confusing for use

I'm working on a babel polyfill proof-of-concept here right now, most things work, there are a few bugs I'm trying to squash with the ... operator in babel.

1 Like

Most compact would be having no token at all ;)

["dogs", hasCats ? "cats"]
["horse", cow ??]
[].concat(a, b ?? [], c || [])

This will add b to the array if it's non-nullish, and c if it's truthy, and the array will end up with 1, 2, or 3 elements accordingly.

I've had the same need for this in my code a few times. If it's added for arrays, I'd argue it should also be added for function arguments:

function f(...a) { return a.length; }
const b =  null;
// f(0, 1, ...[b ??]); // 2
f(0, 1, b ??); // 2

I quite like lightmare's proposal. Very readable.

Thanks for the feedback lightmare, @ljharb, and @sirisian!

I believe the argument of just using concat instead conflicts with the addition and heavy use of ES6's spread operator. The spread operator offers a concise readable syntax when adding elements to an array or map when concat could also work, and also allowing for for arbitrary ordering.

In addition, I believe the concat solution is suboptimal for readability the same way that …(a ?? []) is, and how the spread operator isn’t. Another programmer reading the code with a concat needs to keep an internal stack for understanding the layering of components in order to realize nothing will be added to the array.

Furthermore, as I mentioned before, there is no way to choose where in the array you want to conditionally add an object to. This could be important for things like element ordering in UIs. To my knowledge this case is not surmountable with something like chained calls to an array function like splice, as the addition of one element to an array can change the index to add a next one, such as in [a, b, c ??, d ??, e].

There also isn’t a function to my knowledge that would allow this behavior for function arguments, leading to the same readability issues as in 2 paragraphs above. There’s also a potential issue of loss of immutability with that solution, which is of debatable importance.

Per lightmare's suggestion and sirisan's comment, I also really like it - I was originally concerned about readability with a very short/no operator, however I think it has enough contrast with spaces and the ? while staying concise that it could be the most readable. Once concern I have is if you want to not add something to an array on a true case, where extending this syntactically would be¸ a ? : c which I don't think is very readable, however the programmer can just invert the value of their expression. I also like sirisian's extension for function arguments. I try out the polyfill for this, I don't think it will be that hard after now knowing how to do it for '...'.

Here's a related thread: Optional keys in objects

2 Likes

I would like to see a consistent solution across all places where I can use the spread operator (objects, arrays, and function calls).

I think postfix ? or ?? would fit better than spread operator, because the latter stands for "expand something" rather than "leave out something".

Examples:

const result = someFn();

const arr = [1, 2, result??, ...otherArr];
const obj = { a: 1, b: 2, c: result??, ...otherObj };
otherFn(1, 2, result??, ...otherArgs);

Things to think about:

  • ? or ??? I think ?? is best to reflect that it is about nullish values.

  • undefined only, or nullish? Maybe not introduce a third concept of "falsy" and stick to nullish.

  • shorthand property syntax: { result?? }

  • operator after object key? { key??: result }

Thumbs up from me. Completely agree on the usefulness of this proposal. It addresses a regular irritation. Personally I like the 'sans token' suggestion from @lightmare - it feels like it could work broadly (and elegantly) with most operators and concepts (@aiddun the a ?: c case does actually feel legible / logical to me .. tho note a lack of space between ? and :). Also prefer that it is invoked on the value side, as this works for [] and Set().

So ..

const pets = ["Dogs", hasCats ? "Cats"] // a ? b : <empty>
|| ["Dogs", noCats ?: "Cats"]  // a ?  <empty> : b
|| ["horse", cow ??] // a ?? <empty>
|| {
  dogs: true,
  ...defaultCatsAndPigs,
  cats: hasCats ||,  // a || <empty>
  pigs: hasPigsOrNull ?? } // b ?? <empty>
|| new Set(['Dogs']).add(hasCats ? "Cats") // <empty> ignores 'add'
|| new Map([["cats", 1]]).set('cats', lostCat ? 0) // <empty> ignores 'set'

That said, given the hell this kind of 'missing' syntax would play with the language, I wonder if a suitable compromise could be achieved with a new primitive or global object, rather than an operator ... e.g. an <empty> keyword. Polyfills / pre-processors could step in to afford the above syntax or similar.

To be completely honest, i'd also be mostly happy with it only working on { key: value } objects in the short term, as array-like iterables are easier to work around with concat([]) hacks.

Dart has this feature and they call it "Collection control-flow operators", see: https://dart.dev/language/collections#control-flow-operators

The specific one discussed here would be their collection if. Their example, translated to JS, would look like:

const nav = ['Home', 'Furniture', 'Plants', if (promoActive) 'Outlet'];

They also have a collection for, which would look like this instead:

const listOfInts = [1, 2, 3];
const listOfStrings = ['#0', for (let i of listOfInts) `#${i}`];
assert(listOfStrings[1] === '#1');

It would be amazing to see this added to JS :smile:

2 Likes

You can already do this, since at least ES3:

const nav = [].concat('Home', 'Furniture', 'Plants', promoActive ? 'Outlet' : []];

const listOfInts = [1, 2, 3];
const listOfStrings = [].concat('#0', listOfInts.map(i => `#${i}`)];
assert(listOfStrings[1] === '#1');

@ljharb I agree, but this doesn't work for cases where there are many conditional elements at different positions in a list surrounded by constant ones.

For example, we have a piece of code at work that's like

const onboardingComponents = [
Welcome,
...(isFirefox ? EnableFirefoxPersistance : [])
EnableNotifications,
CreateAccount.
...(isDesktop ? [] : EnablePWA),
...(isEurope? GDPRAccept : []),
VerifyEmail,
...(is2FASMS ? Set2FA : [])
]

Which could be much easier to write and understand with this syntax.

Yes, it precisely works for that case:

const onboardingComponents = [].concat(
Welcome,
isFirefox ? EnableFirefoxPersistance : [],
EnableNotifications,
CreateAccount,
isDesktop ? [] : EnablePWA,
isEurope? GDPRAccept : [],
VerifyEmail,
is2FASMS ? Set2FA : [],
);

While ternarys are a way to do this today, I have found myself wanting to be able to avoid the extra spread noise when optionally defining object properties:

Say there is existing config:

const config = {
  a: 1,
  b: {
    c: 3,
  }
};

Many times I have seen it evolve into:

const config = {
  a: 1,
  ...(x ? { d: 4 } : {}),
  b: {
    c: 3,
    ...(y ? { e: 5 } : {}),
  }
};

When maybe it could have become something like:

const config = {
  a: 1,
  d?: x ? 4 : undefined,
  b: {
    c: 3,
    e?: y ? 5 : undefined,
  }
};
2 Likes

Maybe a discard assignment a la discard bindings?

const config = {
  a: 1,
  d: x ? 4 : void,
  b: {
    c: 3,
    e: y ? 5 : void,
  }
};
3 Likes

I am wildly in favor of that, and it'd also fit very naturally with conceptual object/array comprehensions.

Would any members be interested in championing this proposal?

What exactly does championing a proposal entail? :thinking:

If I could do something to get the Dart-style syntax approved I might be down to try (I feel like the discard binding syntax would still be a bit too unintuitive)

Championing first requires being a TC39 delegate or invited expert.

It's not obvious what the equivalent for arrays should do: create a hole or skip the entry altogether?

2 Likes

Hole is the closest semantically to objects, but skipping would be less surprising and just all around more useful.

Skipping would let me get rid of many conditional array pushes, and ...(cond ? [entry] : []), is not very readable at all (but is a common idiom in Redux and the like). And the syntax would be especially valuable for records and tuples.

(Ignore my last message - I didn't read the context closely enough.)