Skip to content

Instantly share code, notes, and snippets.

@rbuckton
Last active July 27, 2022 22:51
Show Gist options
  • Save rbuckton/e49581c9031a73edd0fce7a260748994 to your computer and use it in GitHub Desktop.
Save rbuckton/e49581c9031a73edd0fce7a260748994 to your computer and use it in GitHub Desktop.

Notational Conventions

Examples in this document may use ECMAScript internal-values as specified in https://tc39.es/ecma262/#sec-value-notation. Such values will appear in ECMAScript examples as ~value-name~ and are not intended to denote new synatx.

This document uses abstract operations defined either in https://github.com/tc39/proposal-pattern-matching or in this document. Such abstract operations will be differentiated from normal ECMAScript functions by names like %OperationName% and are not intended to denote new syntax.

Portions of this document are enclosed in block-quotes. These sections reference portions of https://github.com/codehag/pattern-matching-epic.

Abstract Operations

The following abstract operations are defined in https://tc39.es/proposal-pattern-matching/#sec-custom-matcher:

function %InvokeCustomMatcher%(val, matchable) { /*...*/ }

The following abstract operations are defined in this document:

function %InvokeCustomMatcherOrThrow%(val, matchable) {
  const result = %InvokeCustomMatcher%(val, matchable);
  if (result === ~not-matched~) {
    throw new TypeError();
  }
  return result;
}

Layer 1: Base Proposal (modified)

from pattern-matching-epic:

function isOk(response) {
  return response.status === 200;
}

let { body } when isOk(response);
const { body } when isOk(response);
var { body } when isOk(response);

Extractors can serve the same purpose as when, but align with existing variable syntax that has an Initializer:

// extractors
let isOk{ body } = response;
const isOk{ body } = response;
var isOk{ body } = response;

// equivalent to
let { body } = %InvokeCustomMatcherOrThrow%(isOk, response);
const { body } = %InvokeCustomMatcherOrThrow%(isOk, response);
var { body } = %InvokeCustomMatcherOrThrow$(isOk, response);

from pattern-matching-epic:

// if we ever allow let statements in if statements, we can do this.
if (let { body } when isOk(value)) {
  handle(body);
}
// if we ever allow let statements in if statements, we can do this.
if (let isOk{ body } = value) {
  handle(body);
}

// equivalent to
var _a;
if (_a = %InvokeCustomMatcher%(isOk, value), _a !== ~not-matched~) {
  let { body } = _a;
  handle(body);
}

from pattern-matching-epic:

// note:

let foo = when isOK(value); // foo will be a boolean. This is also fine, but weird to use when here. Maybe it should be disallowed.

While I definitely believe that Extractors should be valid assignment patterns (and thus safe to use in expressions), I do not believe the resulting value here is quite so clear cut:

// note:
let foo = isOk{} = value; // TBD. Normal assignment pattern would pass through RHS, but this could potentially be a boolean instead.

from pattern-matching-epic:

This can be used in many other cases

const responses = [
  {status: 200, body: "a body"},
  /* ... etc */
];

// continue if isOk is not true
for (let { body } when isOk of responses) {
  handle(body);
}
// continue if isOk is not true
for (let isOk{ body } of responses) {
  handle(body);
}

// NOTE: parallels destructuring:
for (let { body } of responses) ...;

// equivalent today:
for (const response of responses) {
  const _a = %InvokeCustomMatcher%(isOk, response);
  if (_a === ~not-matched~) continue;
  let { body } = _a;
  handle(body);
}

from pattern-matching-epic:

Again, if we ever allow assignment in this case

while (let { body } when isOk(responses.pop())) {
 handle(body);
}

The equivalent today

while (responses.length()) {
  const response = responses.pop();
  if (isOk(response) {
    handle(response.body);
  }
}

I disagree with the equivalent result in this proposal, as responses.length() is not mentioned anywhere in the source and cannot realistically be inferred from usage. Instead, I would intead expect while to break if there was no match:

while (let isOk{ body } = responses.pop()) {
  handle(body);
}

// equivalent today:
var _a;
while (_a = %InvokeCustomMatcher%(isOk, responses.pop()), _a !== ~not-matched~) {
  let { body } = _a;
  handle(body);
}

This early break behavior more closely mirrors the behavior of the while let statement in Rust.

from pattern-matching-epic:

If we are doing object iteration, then likely we have a reason to check the url and can handle that in a separate function.

const responseList = {
  "myURl": {status: 200, body: "a body"},
  /* ... etc */
}

function isOkPair([key, response]) {
  if (inAllowList(url)) {
    return response.status == 200;
  }
  return false;
}

for (let [url, { body }] when isOkPair in responseList) {
  handle(body);
}

I do not have an equivalent for the for..in case and do not think there should be one. You could easily use Object.entries() in a for..of instead, and I would rather not overcomplicate for..in.

Layer 2: Fixing switch (modified)

I disagree with the author that we could potentially continue to use switch over match due to both the parser complexity in distinguishing between the two as well as the burden placed on a reader of the code to be able to distinguish between a normal switch and a pattern-matching switch due to a normal switch allowing let/const declarations as statements.

That said, I will amend the examples from the document to illustrate how Extractors could serve the same purpose as a basis for further discussion.

from pattern-matching-epic:

function isGo(command) {
  const validDirections = ["north", "east", "south", "west"];
  return command[0] === "go" && validDirections.includes(command[1]);
}

function isTake(command) {
  const isValidItemString = /[a-z+ ball]/;
  return command[0] === "take"
         && isValidItemString.match(command[1])
         && command[1].weight;
}

switch (command) {
  let [, dir] when isGo: go(dir);
  let [, item] when isTake: take(item);
  default: lookAround();
}
switch (command) {
  let isGo(, dir): go(dir);
  let isTake(, item): take(item);
  default: lookAround();
}

from pattern-matching-epic:

function isGo(command) {
  const validDirections = ["north", "east", "south", "west"];
  return command[0] === "go" && validDirections.includes(command[1]);
}

function isTake(command) {
  const isValidItemString = /[a-z+ ball]/;
  return command[0] === "take"
         && isValidItemString.match(command[1])
         && command[1].weight;
}

match (command) {
  let [, dir] when isGo: go(dir);
  let [, item] when isTake: take(item);
  default: lookAround();
}

The following uses the current proposed pattern matching syntax along with Extractors:

match (command) {
    when (isGo(, dir)): go(dir);
    when (isTake(, item)): take(item);
    default: lookAround();
}

from pattern-matching-epic:

function maybeRetry(res) {
  return res.status == 500 && !this.hasRetried;
}

match (res) {
  let { status, body, ...rest } when { status: 200}: handleData(body, rest)
  let { destination: url } when { status and status >= 300 and status < 400 }:
    handleRedirect(url)
  when maybeRetry.bind(this): { // can alternatively be a higher order function
    retry(req);
    this.hasRetried = true;
  }

  default: throwSomething();
}

This differs somewhat from the current proposal to apply the condition inline, but could still potentially leverage an if clause. This also differs in that it binds maybeRetry outside of the match clause due to the current syntactic restrictions on Extractors to only allow "qualified names" (i.e., a, a.b, a.b.c, etc.). This could be lifted if we chose a different placeholder syntax than ${} (since ${} conflicts with Extractors):

const boundRetry = maybeRetry.bind(this);
match (res) {
    when ({ status: 200, body, ...rest }): handleData(body, rest);
    when ({ status: >= 300 and < 400, destination: url }): handleRedirect(url);
    when (boundRetry{}): do {
        this.hasRetried = true;
        retry(req);
    };

    // alternatively, using `^` from an earlier iteration of the proposal:
    when ( ^(maybeRetry.bind(this)) {}) : do {
        //  ^^^^^^^^^^^^^^^^^^^^^^^ - expression is evaluated
        // ^ - result is interpolated
        //                          ^^ - interpolated result is treated as an Extractor
    }
}

from pattern-matching-epic:

With just these pieces, we can implement a more complex use case, which is Option matching! This would make a good proposal! with Option, Ok, None, Error etc.

class Option {
  #value;
  #hasValue = false;

  constructor (hasValue, value) {
    this.#hasValue = !!hasValue;
    if (hasValue) {
      this.#value = value;
    }
  }

  get value() {
    if (this.#hasValue) return this.#value;
    throw new Exception('Can’t get the value of an Option.None.');
  }

  isSome() {
    return !!this.#hasValue;
  }

  isNone() {
    return !this.#hasValue;
  }

  static Some(val) {
    return new Option(true, val);
  }

  static None() {
    return new Option(false);
  }
}

// the is methods can of course be static, there is flexibility in how someone wants to implement this.
match (result) {
  let { value } when result.isSome: console.log(value());
  when result.isNone: console.log("none");
}
class Option {
    static Some;
    static {
        class Some extends Option {
            #value;

            constructor(value) {
                this.#value = value;
            }
    
            get value() {
                return this.#value;
            }

            static {
                Option.Some = value => new Some(value);
                Option.Some[Symbol.matcher] = obj => #value in obj
            }
        }
    }

    static None;
    static {
        class None extends Option {
            #_;

            static {
                const none = new None();
                Option.None = () => none;
                Option.None[Symbol.matcher] = obj => #_ in obj;
            }
        }
    }
}

match (result) {
    when (Option.Some{ value }): console.log(value);
    when (Option.None): console.log("none");
}

from pattern-matching-epic:

Similarily, builtins can all have an is brand check

match (value) {
  when Number.isNumber: ... // currently missing
  when BigInt.isBigInt: ... // currently missing
  when String.isString: ... // currently missing
  when Array.isArray: ...
  default: ...
}

Extractors don't necessarily help here, but the existing pattern matching proposal already covers this case.

Layer 2: Custom Matchers (modified)

from pattern-matching-epic:

Builtin Regex {
  static {
    Regex[Symbol.matcher] = (val) => ({
      matched: // ...,
      value: // ...,
    });
  }
}

match (arithmeticStr) {
  let { groups: [left, right]} when (/(?<left>\d+) \+ (?<right>\d+)/): process(left, right);
  let [, left, right] when (/(\d+) \* (\d+)/: process(left, right);
  default: ...
}
match (arithmeticStr) {
    when (/(?<left>\d+) \+ (?<right>\d+)/{ groups: { left, right } }): process(left, right);
    when (/(\d+) \* (\d+)/(, left, right)): process(left, right);
    default: ...
}

from pattern-matching-epic:

This also means, we can now write option like so:

class Option {
  #value;
  #hasValue = false;

  constructor (hasValue, value) {
    this.#hasValue = !!hasValue;
    if (hasValue) {
      this.#value = value;
    }
  }

  get value() {
    if (this.#hasValue) return this.#value;
    throw new Exception('Can’t get the value of an Option.None.');
  }

  static Some(val) {
    return new Option(true, val);
  }

  static None() {
    return new Option(false);
  }

  static {
    Option.Some[Symbol.matcher] = (val) => ({
      matched: #hasValue in val && val.#hasValue,
      value: val.value,
    });

    Option.None[Symbol.matcher] = (val) => ({
      matched: #hasValue in val && !val.#hasValue
    });
  }
}

match (result) {
  // note, we are returning the unwrapped value, so we don't need destructuring
  let val when Option.Some: console.log(val);
  when Option.None: console.log("none");
}

I primarily favor a syntax that is consistent between declaration, construction, and destructuring, especially as to how this might relate to ADT Enums (1, 2). For example:

enum Message of ADT {
    Quit,
    Move{ x, y },
    Write(text),
    Color(red, green, blue),
}

// declared above as: Move{ x, y }
const msg1 = Message.Move{ x: 0, y: 1 };
const Message.Move{ x, y } = msg1;

// declared above as: Write(text)
const msg2 = Message.Write("foo");
const Message.Write(text) = msg2;

// declared above as Color(red, green, blue)
const msg3 = Message.Color(255, 255, 0);
const Message.Color(r, g, b) = msg3;

Layer 3: Pattern Matching Syntax

The (optional) Base Case: introducing is

from pattern-matching-epic:

Let's rewind a bit and consider an early case. Given this pattern:

function isOk(response) {
  return response.status == 200;
}

let { body } when isOk(response);

What if we could rewrite it as:

let { body } when response is { status: 200 };

We can also write it in if statements

if (when response is { status: 200 }) {
  // ... do work when response matches something
}

In an unknown future, if potentially we allow the following:

if ( let x = someMethod()) {
  // ... do work when x is not null
}

we could additionally allow:

if ( let { body } when response is { status: 200 }) {
  // ... do work when body is not null
}

This is totally optional. This can, by the way, be dropped. Introducing an is keyword is totally optional.

I like is as pattern-matching equivalent of instanceof and ===, but as an infix-operator (i.e., no leading when):

if (response is { status: 200 }) ...;
if (text is "foo" or "bar") ...;
if (text is /^\d+$/) ...;
if (opt is Option.Some{ value: let value }) print(value);
//                             ^^^ - assuming some sort of inline-`let`/`const`...

Implicit values (modified)

from pattern-matching-epic:

Going back to a more orthodox case, we have implicit values.

match (command) {
  let [, dir] when [ 'go', ('north' or 'east' or 'south' or 'west')]: go(dir);
  let [, item] when [ 'take', (/[a-z]+ ball/ and { weight })]: take(item);
  default: lookAround();
}

However, implicit values can also apply to other proposals, as we are no longer tied to the match statement. Consider

try {
  something();
} catch when isStatus500 {
  // handle it one way...
} catch when isStatus402 {
  // handle the other way...
} catch (err) {
  // catch all...
}

Something like this could be a dependency of layer 2 work, and eventually get the same benefits from layer 4 work.

try {
  something();
} catch when {status: 500} {
  // handle it one way...
} catch when {status: 402} {
  // handle the other way...
} catch (err) {
  // catch all...
}

I see catch clauses as a definite use case for pattern matching, and there is already a seperate effort to introduce syntax.

try {
    something();
} catch when (HttpError{ status: 500 }) {
    // handle it one way...
} catch when (HttpError{ status: 402 }) {
    // handle the other way...
} catch (err) {
    // catch all
}

from pattern-matching-epic:

A more complex example is this one (without the if statement):

match (res) {
  let { data: [page] } when { data: [page] }: ...
  let { data: [frontPage, ...pages ]} when { data: [frontPage, ...pages ]}: ...
  default: { ... }
}

This isn't ideal as we are repeating ourselves. So, we might fall back on functions here:

function hasOnePage(arg) { arg.data.length === 1 }
function hasMoreThanOnePage(arg) { arg.data.length > 1 }
match (res) {
  let { data: [page] } when hasOnePage: ...
  let { data: [frontPage, ...pages ]} when hasMoreThanOnePage: ...
  default: { ... }
}

This example doesn't apply to Extractors, but the existing pattern matching syntax handled this case well enough:

match (res) {
    when ({ data: [page]}): ...;
    when ({ data: [frontPage, ...pages]}): ...;
    default: ...;
}

from pattern-matching-epic:

We can consider primatives, where we can default to ===:

const LF = 0x0a;
const CR = 0x0d;

// default to === for primatives
match (nextChar()) {
  when LF: ...
  when CR: ...
  default: ...
}

match (nextNum()) {
  when 1: ...
  when 2: ...
  default: ...
}

I believe the existing pattern matching proposal handled this well enough, though I prefer the ^ operator over ${}:

match (nextChar()) {
  when (^LF): ...;
  when (^CR): ...;
  default: ...;
}

from pattern-matching-epic:

// works the same way in single matchers.
let nums = [1, 1, 1, 1, 1, 2, 1, 1, 1, 1]
while (when 1 is nums.pop()) {
  count++
}

while ... when ... is doesn't follow the English language reading order used primarily within ECMAScript. The operand order also seems inverted here, as I would expect x is y to imply that x is the subject and y is the type/pattern/domain/etc. Simple infix is with the pattern as the second operand seems more natural when compared to the rest of the language:

// LeftHandSideExpression `is` Pattern
while (nums.pop() is 1) {
    count++;
}

This also aligns better with existing binary operations, such as:

x === 1
x is 1

x instanceof Foo
x is Foo

x is /\d+/
x is Message.Write("hi")

from pattern-matching-epic:

// something additional to consider
const responses = [..alistofresponses];
while (let {body} when {status: 200} is responses.pop()) {
  handle(body);
}
const responses = [..alistofresponses];
// assuming a future inline `let`/`const`:
while (responses.pop() is { status: 200, body: let body }) {
    handle(body);
}

// no inline `let`/`const`:
let body;
while (responses.pop() is { status: 200, body }) {
    //                                   ^^^^ - destructuring assignment
    handle(body);
}

Layer 4: Let-When statements

from pattern-matching-epic:

One of the criticisms I have, for the current proposal, is the unforgiving conflation of assignment and testing. The separation of these two parts allows this proposal to be split into smaller chunks. However, there is a benefit to having conflation. recall this unfortunate example:

match (res) {
  let { data: [page] } when { data: [page] }: ...
  let { data: [frontPage, ...pages ]} when { data: [frontPage, ...pages ]}: ...
  default: { ... }
}

I have the complete opposite position regarding conflation of assignment and matching. My experience with pattern matching in other languages (Scala, C#, Rust, to name a few) aligns with an approach that combines matching and declaration/assignment. Seperating these operations results in significant repetition. It also requires more manual cursor movement in editors that can provide completions and contextual typing, as static type analysis generally flows left-to-right. This has already been shown to be problematic with import ... from ... and binding/assignment patterns, but those only require you to define the source of the contextual type once (either in the from clause of an import/export or the right-hand side of a destructuring assignment). Further splitting out declaration/assignment from the pattern arguably makes this worse as you now must look to two different locations for context.

I strongly prefer inline assignment/declaration. That said, I am more than willing to consider that declarations could be more explicit in some cases by potentially introducing inline let/const/var to pattern matching, similar to C#.

from pattern-matching-epic:

This largely fell out of the previous proposals. However. this is a case where we want intentional conflation. For that, we can have let when.

match (res) {
  let when { data: [page] }: ...
  let when { data: [frontPage, ...pages ]}: ...
  default: { ... }
}

I have no counter example for Extractors for this syntax, as it directly opposes Extractors since when is a legal identifier. I also don't agree with the sentiment that "conflation of assignment and testing" is unforgivable. Also, you are already able to separate assignment from testing in the current pattern matching proposal should you so choose:

const exists = value => value !== undefined;
match (res) {
    when ({ data: [^exists] } and { data: [page] }): ...;
    //    ^^^^^^^^^^^^^^^^^^ - test
    //                           ^^^^^^^^^^^^^^^^ - assignment
}

This proposed seperation also does not work well with pattern matching when two disjoint tests could produce the same binding, and therefore leverage the same match leg:

match (obj) {
    when({ givenName } or { name: { given: givenName }}): console.log(`Hello, ${givenName}`);
    ...
}

from pattern-matching-epic:

Since this has been worked on in a layered way, this applies to the language more broadly:

while (let when {status: 200, body} is responses.pop()) {
  handle(body);
}

A couple of edge cases:

let nums = [1, 1, 1, 1, 1, 2, 1, 1, 1, 1]
while (when 1 is nums.pop()) {
  count++
}

// this will throw, because you can't assign primitives.
while (let when 1 is nums.pop()) {
  count++
}

// this won't throw
while (let x when 1 is nums.pop()) {
  count += x
}

None of these cases apply to Extractors, though I still believe the is operands should be inverted and the when clause is unnecesary. I also disagree with the semantics here: the loop should break when nums.pop() is a value other than 1:

let nums = [1, 1, 1, 1, 1, 2, 1, 1, 1, 1]
while (nums.pop() is 1) {
  count++
}

// `count` would be 5

If this will throw, why allow it in the first place?

// while (let when 1 is nums.pop()) {
//   count++
// }

There are plenty of other ways to achieve this, though if we wanted inline let/const I suppose this would be sufficient, though it would match the semantics I mention above to break when the condition is false.

while (nums.pop() is 1 and let x) {
  count += x
}

from pattern-matching-epic:

// Finally, we can have very intentional aliasing, without the shadowing issue:

match (res) {
  let when { status: 200, body, ...rest }: handleData(body, rest)
  let { destination: url } when { status and status >= 300 and status < 400, destination}:
    handleRedirect(url)
  when maybeRetry.bind(this): { // can alternatively be a higher order function
    retry(req);
    this.hasRetried = true;
  }

  default: throwSomething();
}

I am not clear here on what you mean by "the shadowing issue", but I've already addressed this specific example above.

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