Skip to content

Instantly share code, notes, and snippets.

@modernserf
Last active March 5, 2021 18:44
Show Gist options
  • Save modernserf/1ee804cb37372b62a9c098dcfad758d0 to your computer and use it in GitHub Desktop.
Save modernserf/1ee804cb37372b62a9c098dcfad758d0 to your computer and use it in GitHub Desktop.
Tagged Template Binding Literals

(Tagged) Template Binding Literals

This proposal defines new syntax for destructuring binding with tagged template literals.

Status

Not submitted.

Introduction

This proposal introduces the template binding literal, a destructuring binding pattern which mirrors the template literal expression. In addition to interpolated values (via ${value}), template binding literals can also interpolate bindings, using the syntax @{binding}. These bindings can themselves be destructured.

While template binding literals can be used anywhere a destructuring binding is used (e.g. in variable declarations or function parameters), they will be most useful for pattern matching.

Motivating examples

String destructuring (default, "untagged" behavior)

const text = "The current temperature is 31 F";
const `The current temperature is @{degrees} @{unit}` = text;
// degrees = "31"
// unit = "F"

roughly equivalent to:

const text = "The current temperature is 31 F";
const [_, degrees, unit] = text.match(
  /^The current temperature is (.*?) (.*?)$/
);

Regular expressions

const text = "1999-12-31";
const re`(@{year}[0-9]{4})-(@{month}[0-9]{2})-(@{day}[0-9]{2})` = text;
// year = "1999"
// month = "12"
// day = "31"

Map destructuring

const barKey = Symbol('bar');
const map = new Map([
  ["foo", 1],
  [barKey, 2],
  ["baz", 3],
  ["quux", 4]
]);
const m`{ foo => @{foo}, ${barKey} => @{bar}, ...@{rest} }` = map;
// foo = 1
// bar = 2
// rest = new Map([["baz", 3], ["quux", 4]])

Compact pattern matching on tagged variants

case (action) {
  // matches `{ type: "set-visibility-filter", payload: visFilter }`
  when t`set-visibility-filter @{visFilter}` -> ({...state, visFilter}),
  // matches `{ type: "toggle-sort-direction" }` but binds no values
  when t`toggle-sort-direction` -> ({ ...state, sortDirection: !state.sortDirection }),
  // matches everything else
  when _ -> state
}

Desugaring

Tagged template binding literals desugar as function calls, similarly to their expression counterparts. If the match succeeds, the function should return an array of values which correspond to the values being bound. If the match fails, the function should return null. This is somewhat similar to the format used by RegExp match/exec results, but with only the captured substrings.

const m`{ "foo" => @{foo = 0}, ${barKey} => @{{ x, y }} }` = myMap;

desugars to:

const [foo = 0, { x, y }] = m(
  // first argument: value being matched
  myMap,
  // second argument: template string array
  ['{ "foo" => ', ", ", " => ", " }"],
  // rest arguments: bindings & interpolated values
  // bindings are represented with well-known symbol `Symbol.binding`
  Symbol.binding,
  barKey,
  Symbol.binding
);

Implementing a function for tagged template binding

This function implements the same behavior as an untagged template binding literal:

function matchString(subject, stringParts, ...interpolations) {
  let regExpString = "^";
  for (const str of stringParts) {
    regExpString += escapeRegexp(str);
    if (interpolations.length) {
      const interp = interpolations.shift();
      if (interp === Symbol.binding) {
        regExpString += "(.*?)";
      } else {
        regExpString += escapeRegexp(String(interp));
      }
    }
  }
  regExpString += "$";

  const result = new RegExp(regExpString).exec(subject);
  if (!result) return null;
  return result.slice(1);
}

Why this syntax?

At this point, we must acknowledge that it is rather odd to propose pattern matching via tagged template literals and not pattern matching with function calls. Pattern matching with type constructors is quite common in typed functional languages, and some of them also allow pattern matching with arbitrary functions that take additional parameters, e.g. F#'s Active Patterns.

However, this can result in a somewhat counterintuitive parsing to distinguish between arguments to the pattern function and the pattern being extracted from it. Consider the code:

let datePattern = "(\d{1,4})-(\d{1,2})-(\d{1,2})"
match str with
  | ParseRegex datePattern [Integer y; Integer m; Integer d]
    -> new System.DateTime(y, m, d)

The equivalent form in JS would be something like:

const datePattern = /(\d{1,4})-(\d{1,2})-(\d{1,2})/;
case (str) {
  when ParseRegex(datePattern, [y, m, d]) -> new Date(y, m, d),
}

In both of these examples, the pattern ParseRegex and its first argument datePattern are understood to be expressions, while its last "argument" (the array) is actually the pattern applied to its return value. The potential confusion is mitigated in F# -- the absence of parentheses makes this read a little better, and the type system can prevent you from using the wrong argument order. JavaScript has no affordances here, and this syntax would cause significant confusion.

On the other hand, tagged template binding literals explictly distinguish between interpolated bindings and values, and the relative obscurity of (regular) tagged template literals means that there will be comparatively fewer expectations to violate.

History & Prior Art

Quasi-literals in E

ES6 tagged template literals were heavily influenced by quasi-literals in E, and were even initially called "quasi-literals". Quasis in E can also create bindings (with @{binding} syntax), though they are used on the right-hand side of an =~ expression, not the left-hand side of an assignment:

def line := "By the rude bridge that arched the flood"
if (line =~ `@word1 @{word2} rude @remainder`) {
    println(`text matches, word1 = $word1`)
}

from E in a Walnut

Harmony-era quasi-literals proposal

Was there ever a point where the template tag literals proposal also could create bindings, as they do in E?

Very briefly. There was a JS AST proposal at the time and some trick that passed ASTs of interpolations to a tag function that returned an AST and then there was some trick with eval that let this all be syntactic sugar. It was not well received so never drafted.

My sense of the room was that there was a preference for a good string composition proposal with room for more complex use cases over a bad macros proposal, which in retrospect was a fair characterization.

Mike Samuel, author of the quasis/tagged template ES6 proposal

Regular expressions in Ruby

When named capture groups are used with a literal regexp on the left-hand side of an expression and the =~ operator, the captured text is also assigned to local variables with corresponding names.

/\$(?<dollars>\d+)\.(?<cents>\d+)/ =~ "$3.67" #=> 0
dollars #=> "3"

Class: Regexp (Ruby 2.3.3)

@mbilokonsky
Copy link

mbilokonsky commented Jan 23, 2019

Neat example extrapolations, especially integrating it with optional regex, I love that!

I'm not sure if this fits as cleanly into the 'destructuring' model but was thinking that this could easily take an optional parser callback, too? Let's say you're working in a text-based game and you've got regular lines that look like this:

The player has 30 health and 25 gold. The room contains exits to the north and west.

const playerParser = str => {
  const `${hp} health and ${g} gold.` = str
  return {
    health: parseInt(hp),
    gold: parseInt(g)
  }
}

const parseRoom = str=>  {
  str.split(' ').filter(word => validExits.indexOf(word) > -1).reduce((acc, val) => ({...acc, [val]: linkTo(val)}), {})
}

const `The player has ${player, playerParser }. The room contains exits to the ${exits, roomParser} = text;

console.log(player) // {  health: 30, gold: 25}
console.log(exits) // { north: <function to go north>, west: <function to go west> }

Does that make sense? May be a bit far afield but I feel like there'd be some pretty happy code that would suddenly become pretty easy to write?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment