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.
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;
}
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
.
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.
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;
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`...
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);
}
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.