ECMAScript Tagged Collection Literals



Author: Kat Marchán (npm, @maybekatz)

Champions: Kat Marchán (npm, @maybekatz)


This proposal was presented to TC39 in May 2018 and voluntarily withdrawn during discussions. The author concluded this issue was best solved through an object-destructuring protocol corresponding to sequence iterators, in combination with Map.from() and company.

JavaScript, unlike a lot of the static languages pattern matching concepts were based on, does not include enough static typing information to make things like destructuring or pattern matching efficient and cacheable enough to work well in these cases and stay performant. Additionally, this additional syntax did not add enough value to be worth the syntax budget expenditure.

A different proposal will be submitted based on this one that talks about creation/extraction protocols in a unified way, but without adding additional syntax.


This proposal extends both destructuring binding/assignment and the match statement with the ability to apply custom destructuring and matching operations to matched data. It also adds a new constructor syntax for building these custom data structures with concise object and array style syntax while preserving their individual benefits and invariants.

The syntax itself is derived from similar syntax in multiple other languages used for this purpose, and is meant to be reminiscent of tagged template literals in JavaScript -- except using other literal syntaxes available in the language.

This proposal is derived from a previously-discussed extensible collection literals, but adds significant work as far as how this syntax interacts with destructuring and matching.

Motivating Examples

Convenient construction of object-like and array-like data structures:

const map = Map!{1: 2, three: 4, [[5]]: 6, 1: 'again'}
// Map { 1 => 'again', 'three' => 4, [5] => 6 }

const set = Set![1,2,3,2]
// Set [1, 2, 3]

const opt = Some!1
// Some { value: 1 }

Destructuring assignment/binding:

const Map!{1: x, three: y} = map
x // 2
y // 4

const Set![x,y] = set
x // 1
y // 2

const Some!x = opt
x // 1

Match statement compatibility:

match (input) {
  when Map!{1: x, 2: y} ~> ...
  when /(?<year>\d{4})-(?<month>\d{2})/u!{groups: {year, month}} ~> {
    console.log(`The year is ${year}, and the month is ${month}`)
  when Some!1 ~> `option succeeded with an internal value of 1`
  when Some!x ~> `option succeeded with a non-1 value of ${x}`,
  when None!{} ~> `option failed`

The Big Picture

Construction Literals

For construction, literals are a thin layer of syntax sugar over Constructor.from() functions. When a literal construction expression is found, the left hand side is evaluated for its value, and the right hand side is converted to an iterator or an atomic value. The type of value passed to .from() depends on which of the three syntaxes is used:

// Tagged Object Literals
Map!{foo: 1, 'foo': 1, [Symbol('bar')]: 2, 3: 4, [{}]: 5}
=== Map.from({[Symbol.iterator]: function* () {
  // IdentifierName interpreted as string
  yield ['foo', 1]
  // StringLiteral PropertyNames
  yield ['foo', 1]
  // Symbols preserved
  yield [Symbol('bar'), 2]
  // Numeric literals do not get ToString
  yield [3, 4]
  // Other kinds of computer property also do not get ToString
  yield [{}, 5]

// Tagged Array Literals
=== Set.from({[Symbol.iterator]: function* () {
  // Nothing special here, except the argument is not an Array
  yield 1; yield 2; yield 3

// Tagged Value Literals
=== Some.from(1)


For Objects, the benefits are more obvious: No conversion to ToString, parse-time early errors for invalid key/value syntax, and more appropriate syntax for key/value types, instead of having to write nested arrays.

For Arrays and Values, the benefit on this end of things is smaller, and largely based on convenience, with the exception that it does help ease a common footgun with new:

class Bar { constructor (iter) { this.val = [...iter] } }
function foo () { return {Bar} }
foo.Bar = Bar
new foo().Bar()
// TypeError: Class constructor Bar cannot be invoked without `new`
new foo.Bar()
// => Bar {}

While this behavior is consistent, it is something that does occasionally bite people. Literal syntax helps ease this a bit, specially in data structure-heavy code:

// Bar { val: [1,2,3] }
// Bar { val: [1,2,3] }

But, as implied before, the main benefit of extending tagged literal syntax to arrays and individual values is the correspondence to destructuring...

Destructuring Literals

When a user learns they can construct with one syntax, is becomes much easier to teach them how to destruct with it.

While the common .from() method mechanism is what makes construction literals work, destructuring uses the standard iterator protocol through a Symbol.valueOf constructor method. If Symbol.valueOf is not present, .valueOf() is tried instead. If array or object-destructurng syntax is used without RestProperty/RestElement, .valueOf() will receive an array of keys that are being requested from the object. If an atomic destructure is requested, the second argument to valueOf() will be undefined. Filtering entries based on these keys is optional.

The valueOf method should return an iterator

const Map!{1: x, y} = Map!{1: 'x', y: 'y'}
let x, y
for (let entry of Map.valueOf(Map!{1: 'x', y: 'y'}, [1, 'y'])) {
  match (entry) {
    when [1, _x] ~> {
      x = _x
    when ['y', _y] ~> {
      y = _y

const Set![a, b, c] = Set![1,2,3,4]
let [a,b,c] = Array.from(Set.valueOf(Set![1,2,3,4], [0,1,2]))

class Some { constructor (val) { this._val = val } }
Some.valueOf = (some) => some._val

const Some!x = Some!1
let x = Some.valueOf(new Some(1))
// x === 1

When a destructuring sequence is used, the iterator will be used as-is to match and fill entries in the destructured array or object.

If valueOf returns undefined, the match is considered to have failed, and no values will match. When used with match, this will cause the match clause to fail.