ECMAScript Pattern Matching
Status
Stage: 1
Spec Text: https://tc39.github.io/proposal-pattern-matching
Authors: Originally Kat Marchán (Microsoft, @zkat__); now, the below champions.
Champions: (in alphabetical order)
- Daniel Rosenwasser (Microsoft, @drosenwasser)
- Jack Works (Sujitech, @Jack-Works)
- Jordan Harband (@ljharb)
- Mark Cohen (Netflix, @mpcsh_)
- Ross Kirsling (Sony, @rkirsling)
- Tab Atkins-Bittner (Google, @tabatkins)
Table of Contents
- Problem
- Priorities
- Prior Art
- Code Samples
- Motivating Examples
- Terminology/Proposal
- Possible Future Enhancements
Introduction
Problem
There are many ways to match values in the language, but there are no ways to
match patterns beyond regular expressions for strings. switch
is severely
limited: it may not appear in expression position; an explicit break
is
required in each case
to avoid accidental fallthrough; scoping is ambiguous
(block-scoped variables inside one case
are available in the scope of the
others, unless curly braces are used); the only comparison it can do is ===
;
etc.
Priorities for a solution
This section details this proposal’s priorities. Note that not every champion may agree with each priority.
Pattern matching
The pattern matching construct is a full conditional logic construct that can do more than just pattern matching. As such, there have been (and there will be more) trade-offs that need to be made. In those cases, we should prioritize the ergonomics of structural pattern matching over other capabilities of this construct.
switch
Subsumption of This feature must be easily searchable, so that tutorials and documentation are
easy to locate, and so that the feature is easy to learn and recognize. As such,
there must be no syntactic overlap with the switch
statement.
This proposal seeks to preserve the good parts of switch
, and eliminate any
reasons to reach for it.
switch
Be better than switch
contains a plethora of footguns such as accidental case fallthrough and
ambiguous scoping. This proposal should eliminate those footguns, while also
introducing new capabilities that switch
currently can not provide.
Expression semantics
The pattern matching construct should be usable as an expression:
return match { ... }
let foo = match { ... }
() => match { ... }
- etc.
The value of the whole expression is the value of whatever clause is matched.
Exhaustiveness and ordering
If the developer wants to ignore certain possible cases, they should specify that explicitly. A development-time error is less costly than a production-time error from something further down the stack.
If the developer wants two cases to share logic (what we know as "fall-through"
from switch
), they should specify it explicitly. Implicit fall-through
inevitably silently accepts buggy code.
Clauses should always be checked in the order they’re written, i.e. from top to bottom.
User extensibility
Userland objects should be able to encapsulate their own matching semantics, without unnecessarily privileging builtins. This includes regular expressions (as opposed to the literal pattern syntax), numeric ranges, etc.
Prior Art
This proposal adds a pattern matching expression to the language, based in part on the existing Destructuring Binding Patterns.
This proposal was approved for Stage 1 in the May 2018 TC39 meeting, and slides for that presentation are available here. Its current form was presented to TC39 in the April 2021 meeting (slides).
This proposal draws from, and partially overlaps with, corresponding features in CoffeeScript, Rust, Python, F#, Scala, Elixir/Erlang, and C++.
Userland matching
A list of community libraries that provide similar matching functionality:
- Optionals — Rust-like error handling, options and exhaustive pattern matching for TypeScript and Deno
- ts-pattern — Exhaustive Pattern Matching library for TypeScript, with smart type inference.
- babel-plugin-proposal-pattern-matching — Minimal grammar, high performance JavaScript pattern matching implementation.
- match-iz — A tiny functional pattern-matching library inspired by the TC39 proposal.
- patcom — Feature parity with TC39 proposal without any new syntax
Code samples
General terminology
match (res) {
when ({ status: 200, body, ...rest }): handleData(body, rest)
when ({ status, destination: url }) if (300 <= status && status < 400):
handleRedirect(url)
when ({ status: 500 }) if (!this.hasRetried): do {
retry(req);
this.hasRetried = true;
}
default: throwSomething();
}
match expression
-
The whole block beginning with the
match
keyword, is the match construct. -
res
is the matchable. This can be any expression. -
There are four clauses in this example: three
when
clauses, and onedefault
clause. -
A clause consists of a left-hand side (LHS) and a right-hand side (RHS), separated by a colon (
:
). -
The LHS can begin with the
when
ordefault
keywords.- The
when
keyword must be followed by a pattern in parentheses. Each of thewhen
clauses here contain object patterns. - The parenthesized pattern may be followed by a guard, which
consists of the
if
keyword, and a condition (any expression) in parentheses. Guards provide a space for additional logic when patterns aren’t expressive enough. - An explicit
default
clause handles the "no match" scenario by always matching. It must always appear last when present, as any clauses after andefault
are unreachable.
- The
-
The RHS is any expression. It will be evaluated if the LHS successfully matches, and the result will be the value of the entire match construct.
- We assume that
do
expressions will mature soon, which will allow users to put multiple statements in an RHS; today, that requires an IIFE.
- We assume that
is expression
const problematic = res is { status: 500 };
if (problematic) logger.report(res);
- A new RelationalExpression like
a instanceof b
anda in b
, but for patterns. - LHS is an expression, and RHS is a pattern.
More on combinators
match (command) {
when ([ 'go', dir and ('north' or 'east' or 'south' or 'west')]): go(dir);
when ([ 'take', item and /[a-z]+ ball/ and { weight }]): take(item);
default: lookAround()
}
This sample is a contrived parser for a text-based adventure game.
The first clause matches if the command is an array with exactly two items. The
first must be exactly the string 'go'
, and the second must be one of the given
cardinal directions. Note the use of the
and combinator to bind the second item in the
array to dir
using an identifier pattern before
verifying (using the or combinator) that it’s one of the
given directions.
(Note that there is intentionally no precedence relationship between the pattern
operators, such as and
, or
, or with
; parentheses must be used to group
patterns using different operators at the same level.)
The second clause showcases a more complex use of the
and combinator. First is an
identifier pattern that binds the second item in the
array to item
. Then, there’s a regex pattern that checks if
the item is a "something ball"
. Last is an object pattern,
which checks that the item has a weight
property (which, combined with the
previous pattern, means that the item must be an exotic string object), and
makes that binding available to the RHS.
Array length checking
match (res) {
if (isEmpty(res)): ...
when ({ data: [page] }): ...
when ({ data: [frontPage, ...pages] }): ...
default: { ... }
}
Array patterns implicitly check the length of the incoming matchable.
The first clause is a bare guard, which matches if the condition is truthy.
The second clause is an object pattern which
contains an array pattern, which matches if data
has exactly
one element, and binds that element to page
for the RHS.
The third clause matches if data
has at least one element,
binding that first element to frontPage
, and binding an array of any remaining
elements to pages
using a rest pattern.
(Rest patterns can also be used in objects, with the expected semantics.)
Bindings from regex patterns with named capture groups
match (arithmeticStr) {
when (/(?<left>\d+) \+ (?<right>\d+)/): process(left, right);
when (/(\d+) \* (\d+)/ with [, left, right]): process(left, right);
default: ...
}
This sample is a contrived arithmetic expression parser which uses regex patterns.
The first clause matches integer addition expressions, using named capture groups for each of the operands. The RHS is able to see the named capture groups as bindings.
(These magic bindings will only work with literal regex patterns. If a regex with named capture groups is passed into an interpolation pattern, the RHS will see no magic bindings. It’s very important (e.g. for code analysis tools) that bindings only be introduced where the name is locally present.)
The second clause matches integer multiplication expressions, but without named
capture groups. Regexes (both literals and references inside
interpolation patterns) implement the
custom matcher protocol, which makes the return
value of
String.prototype.match
available to the with
operator.
(Regexes are a major motivator for the custom matcher protocol ― while we could treat them as a special case, they’re just ordinary objects. If they can be used as a pattern, then userland objects should be able to do this as well.)
Speaking of interpolations...
const LF = 0x0a;
const CR = 0x0d;
match (nextChar()) {
when (${LF}): ...
when (${CR}): ...
default: ...
}
Here we see the interpolation operator (${}
),
which escapes from "pattern mode" syntax to "expression mode" syntax. It is
conceptually very similar to using ${}
in template strings.
Written as just LF
, LF
is an identifier pattern,
which would always match regardless of the value of the matchable
(nextChar()
) and bind it to the given name (LF
), shadowing the outer
const LF = 0x0a
declaration at the top.
Written as ${LF}
, LF
is evaluated as an expression, which results in the
primitive Number
value 0x0a
. This value is then treated as a
literal Number pattern, and the clause matches
if the matchable is 0x0a
. The RHS sees no new bindings.
Custom matcher protocol interpolations
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: #value in val && val.#value,
});
Option.None[Symbol.matcher] = (val) => ({
matched: #hasValue in val && !val.#hasValue
});
}
}
match (result) {
when (${Option.Some} with val): console.log(val);
when (${Option.None}): console.log("none");
}
In this sample implementation of the common "Option" type,
the expressions inside ${}
are the static "constructors" Option.Some
and Option.None
,
which have a Symbol.matcher
method. That method is invoked with the
matchable (result
) as its sole argument. The
interpolation pattern is considered to have matched if
the Symbol.matcher
method returns an object with a truthy matched
property.
Any other return value (including true
by itself) indicates a failed match. (A
thrown error percolates up the expression tree, as usual.)
The interpolation pattern can optionally chain into
another pattern using with
chaining, which matches against
the value
property of the object returned by the Symbol.matcher
method;
in this case, it allows Option.Some
to expose the value inside of the Option
.
Dynamic custom matchers can readily be created, opening a world of possibilities:
function asciiCI(str) {
return {
[Symbol.matcher](matchable) {
return {
matched: str.toLowerCase() == matchable.toLowerCase()
};
}
}
}
match (cssProperty) {
when ({ name: name and ${asciiCI("color")}, value }):
console.log("color: " + value);
// matches if `name` is an ASCII case-insensitive match
// for "color", so `{name:"COLOR", value:"red"} would match.
}
Built-in custom matchers
match (value) {
when (${Number}): ...
when (${BigInt}): ...
when (${String}): ...
when (${Array}): ...
default: ...
}
All the built-in classes come with a predefined Symbol.matcher
method which
uses
brand check semantics
to determine if the incoming matchable is of that type. If so, the
matchable is returned under the value
key.
Brand checks allow for predictable results across realms. So, for example,
arrays from other windows will still successfully match the ${Array}
pattern,
similar to Array.isArray()
.
Motivating examples
Below are selected situations where we expect pattern matching will be widely used. As such, we want to optimize the ergonomics of such cases to the best of our ability.
Matching fetch()
responses:
const res = await fetch(jsonService)
match (res) {
when ({ status: 200, headers: { 'Content-Length': s } }):
console.log(`size is ${s}`);
when ({ status: 404 }):
console.log('JSON not found');
when ({ status }) if (status >= 400): do {
throw new RequestError(res);
}
};
More concise, more functional handling of Redux reducers (compare with this same example in the Redux documentation):
function todosReducer(state = initialState, action) {
return match (action) {
when ({ type: 'set-visibility-filter', payload: visFilter }):
{ ...state, visFilter }
when ({ type: 'add-todo', payload: text }):
{ ...state, todos: [...state.todos, { text, completed: false }] }
when ({ type: 'toggle-todo', payload: index }): do {
const newTodos = state.todos.map((todo, i) => {
return i !== index ? todo : {
...todo,
completed: !todo.completed
};
});
({
...state,
todos: newTodos,
});
}
default: state // ignore unknown actions
}
}
Concise conditional logic in JSX (via Divjot Singh):
<Fetch url={API_URL}>
{props => match (props) {
when ({ loading }): <Loading />
when ({ error }): do {
console.err("something bad happened");
<Error error={error} />
}
when ({ data }): <Page data={data} />
}}
</Fetch>
Proposal
Match construct
Refers to the entire match (...) { ... }
expression. Evaluates to the RHS of
the first clause to match, or throws a TypeError if none match.
Matchable
The value a pattern is matched against. The top-level matchable
shows up in match (matchable) { ... }
, and is used for each clause as the
initial matchable.
Destructuring patterns can pull values out of a matchable,
using these sub-values as matchables for their own nested patterns.
For example, matching against ["foo"]
will confirm the matchable itself is an
array-like with one item, then treat the first item as a matchable against the
"foo"
primitive pattern.
Clause
One "arm" of the match construct’s contents, consisting of
an LHS (left-hand side) and an RHS (right-hand side), separated by a colon (:
).
The LHS can look like:
when (<pattern>)
, which matches its pattern against the top-level matchable;if (<expr>)
, which matches if the<expr>
is truthy;when (<pattern>) if (<expr>)
, which does both;default
, which always succeeds but must be the final clause.
The RHS is an arbitrary JS expression, which the whole match construct resolves to if the LHS successfully matches.
(There is an open issue about whether there should be some separator syntax between the LHS and RHS.)
The LHS’s patterns, if any, can introduce variable bindings which are visible to the guard and the RHS of the same clause. Bindings are not visible across clauses. Each pattern describes what bindings, if any, it introduces.
TODO: LHS
TODO: RHS
Guard
The if (<expr>)
part of a clause. The <expr>
sees bindings present at the
start of the match construct; if the clause began with a
when (<pattern>)
, it additionally sees the bindings introduced by the
pattern.
Pattern
There are several types of patterns:
Primitive Pattern
Boolean literals, numeric literals, string literals, and the null literal.
Additionally, some expressions that are almost literals, and function as literals in people’s heads, are allowed:
undefined
, matching the undefined value- numeric literals preceded by an unary
+
or-
, like-1
NaN
Infinity
(with+
or-
prefixes as well)- untagged template literals, with the interpolation expressions seeing only the bindings present at the start of the match construct.
These match if the matchable is
SameValue
with them,
with one exception:
if the pattern is the literal 0
(without the unary prefix operators +0
or -0
),
it is instead compared with SameValueZero
.
(That is, +0
and -0
only match positive and negative zero, respectively,
while 0
matches both zeroes without regard for the sign.)
They do not introduce bindings.
Identifier Pattern
Any identifier that isn’t a primitive matcher, such as
foo
. These always match, and bind the matchable to the given
binding name.
Regex Pattern
A regular expression literal.
The matchable is stringified, and the pattern matches if the
string matches the regex. If the regex defines named capture groups, those names
are introduced as bindings, bound to the captured substrings. Regex patterns can
use with
-chaining to further match a pattern against the
regex’s match result.
Interpolation pattern
An arbitrary JS expression wrapped in ${}
, just like in template literals. For
example, ${myVariable}
, ${"foo-" + restOfString}
, or ${getValue()}
.
At runtime, the expression inside the ${}
is evaluated. If it resolves to an
object with a method named Symbol.matcher
, that method is invoked, and
matching proceeds with the custom matcher protocol
semantics. If it resolves to anything else (typically a primitive, a Symbol
,
or an object without a Symbol.matcher
function), then the pattern matches if
the matchable is
SameValue
with the result.
Interpolation patterns can use with
-chaining to further
match against the value
key of the object returned by the Symbol.matcher
method.
Array Pattern
A comma-separated list of zero or more patterns or holes, wrapped in square
brackets, like ["foo", a, {bar}]
. "Holes" are just nothing (or whitespace),
like [,,thirdItem]
.
The final item can optionally be either a "rest pattern",
looking like ...
,
or a "binding rest pattern",
looking like ...<identifier>
.
(Aka, an array pattern looks like array destructuring,
save for the addition of the "rest pattern" variant.)
First, an iterator is obtained from the matchable: if the
matchable is itself iterable (exposes a [Symbol.iterator]
method) that is used; if it’s array-like, an array iterator is used.
Then, items are pulled from the iterator, and matched against the array pattern’s corresponding nested patterns. (Holes always match, introducing no bindings.) If any of these matches fail, the entire array pattern fails to match.
If the array pattern ends in a binding rest pattern, the remainder of the iterator is pulled into an Array, and bound to the identifier from the binding rest pattern, just like in array destructuring.
If the array pattern does not end in a rest pattern (binding or otherwise), the iterator must match the array pattern’s length: one final item is pulled from the iterator, and if it succeeds (rather than closing the iterator), the array pattern fails to match.
The array pattern introduces all the bindings introduced by its nested patterns, plus the binding introduced by its binding rest pattern, if present.
Bindings introduced by earlier nested patterns
are visible to later nested patterns in the same array pattern.
(For example, [a, ${a}]
) will match
only if the second item in the array is identical to the first item.)
Array Pattern Caching
To allow for idiomatic uses of generators and other "single-shot" iterators to be reasonably matched against several array patterns, the iterators and their results are cached over the scope of the match construct.
Specifically, whenever a matchable is matched against an array pattern, the matchable is used as the key in a cache, whose value is the iterator obtained from the matchable, and all items pulled from the matchable by an array pattern.
Whenever something would be matched against an array pattern, the cache is first checked, and the already-pulled items stored in the cache are used for the pattern, with new items pulled from the iterator only if necessary.
For example:
function* integers(to) {
for(var i = 1; i <= to; i++) yield i;
}
const fiveIntegers = integers(5);
match (fiveIntegers) {
when([a]):
console.log(`found one int: ${a}`);
// Matching a generator against an array pattern.
// Obtain the iterator (which is just the generator itself),
// then pull two items:
// one to match against the `a` pattern (which succeeds),
// the second to verify the iterator only has one item
// (which fails).
when([a, b]):
console.log(`found two ints: ${a} and ${b}`);
// Matching against an array pattern again.
// The generator object has already been cached,
// so we fetch the cached results.
// We need three items in total;
// two to check against the patterns,
// and the third to verify the iterator has only two items.
// Two are already in the cache,
// so we’ll just pull one more (and fail the pattern).
default: console.log("more than two ints");
}
console.log([...fiveIntegers]);
// logs [4, 5]
// The match construct pulled three elements from the generator,
// so there’s two leftover afterwards.
When execution of the match construct finishes, all cached iterators are closed.
Object Pattern
A comma-separated list of zero or more "object pattern clauses", wrapped in
curly braces, like {x: "foo", y, z: {bar}}
. Each "object pattern clause" is
either an <identifier>
, or a <key>: <pattern>
pair, where <key>
is an
<identifier>
or a computed-key expression like [Symbol.foo]
. The final item
can be a "rest pattern", looking like ...<identifier>
. (Aka, it looks like
object destructuring.)
For each object pattern clause, the matchable must contain a property matching the key, and the value of that property must match the corresponding pattern; if either of these fail for any object pattern clause, the entire object pattern fails to match.
Plain <identifier>
object pattern clauses are treated as if they were written
<identifier>: <identifier>
(just like destructuring); that is, the
matchable must have the named property, and the property’s value
is then bound to that name due to being matched against an
identifier pattern.
If the object pattern ends in a [TODO: rest pattern], all of the
matchable’s own keys that weren’t explicitly matched are bound
into a fresh Object
, just like destructuring or array patterns.
Unlike array patterns, the lack of a final rest pattern imposes no additional
constraints; {foo}
will match the object {foo: 1, bar:2}
, binding foo
to
1
and ignoring the other key.
The object pattern introduces all the bindings introduced by its nested patterns, plus the binding introduced by its rest pattern, if present.
Bindings introduced by earlier nested patterns
are visible to later nested patterns in the same object pattern.
(For example, {a, b:${a}}
) will match
only if the b
property item in the object is identical to the a
property's value.)
Ordering is important, however, so {b:${a}, a}
does not mean the same thing;
instead, the ${a}
resolves based on whatever a
binding might exist from earlier in the pattern,
or outside the match construct entirely.
Object Pattern Caching
Similar to array pattern caching, object patterns cache their results over the scope of the match construct, so that multiple clauses don’t observably retrieve the same property multiple times.
(Unlike array pattern caching, which is necessary for this proposal to work with iterators, object pattern caching is a nice-to-have. It does guard against some weirdness like non-idempotent getters, and helps make idempotent-but-expensive getters usable in pattern matching without contortions, but mostly it’s just for conceptual consistency.)
Whenever a matchable is matched against an object pattern, for
each property name in the object pattern, a (<matchable>, <property name>)
tuple is used as the key in a cache, whose value is the value of the property.
Whenever something would be matched against an object pattern, the cache is
first checked, and if the matchable and that property name are
already in the cache, the value is retrieved from cache instead of by a fresh
Get
against the matchable.
For example:
const randomItem = {
get numOrString() { return Math.random() < .5 ? 1 : "1"; }
};
match (randomItem) {
when({numOrString: ${Number}}):
console.log("Only matches half the time.");
// Whether the pattern matches or not,
// we cache the (randomItem, "numOrString") pair
// with the result.
when({numOrString: ${String}}):
console.log("Guaranteed to match the other half of the time.");
// Since (randomItem, "numOrString") has already been cached,
// we reuse the result here;
// if it was a string for the first clause,
// it’s the same string here.
}
TODO: Rest pattern
Custom Matcher Protocol
When the expression inside an interpolation pattern
evaluates to an object with a Symbol.matcher
method, that method is called
with the matchable as its sole argument.
To implement the Symbol.matcher
method, the developer must return an object
with a matched
property. If that property is truthy, the pattern matches; if
that value is falsy, the pattern does not match. In the case of a successful
match, the matched value must be made available on a value
property of the
return object.
Built-in Custom Matchers
All of the classes for primitive types (Boolean
, String
, Number
, BigInt
)
expose a built-in Symbol.matcher
method, matching if and only if the
matchable is an object of that type, or a primitive corresponding
to that type (using brand-checking to check objects, so boxed values from other
windows will still match). The value
property of the returned object is the
(possibly auto-unboxed) primitive value.
All other platform objects also expose built-in Symbol.matcher
methods,
matching if and only if the matchable is of the same type (again
using brand-checking to verify, similar to Array.isArray()
). The value
property of the returned object is the matchable itself.
Userland classes do not define a default custom matcher (for both practical and technical reasons), but it is very simple to define one in this style:
class Foo {
static [Symbol.matcher](value) {
return {
matched: value instanceof Foo,
value,
};
}
}
with
chaining
An interpolation pattern or a
regex pattern (referred to as the "parent pattern" for the
rest of this section) may also have a with <pattern>
suffix, allowing you to
provide further patterns to match against the parent pattern’s result.
The with
pattern is only invoked if the parent pattern successfully matches.
Any bindings introduced by the with
pattern are added to the bindings from the
parent pattern, with the with
pattern’s values overriding the parent pattern’s
value if the same bindings appear in both.
The parent pattern defines what the matchable will be for the
with
pattern:
- for regex patterns, the regex’s match object is used
- for interpolation patterns that did not invoke the custom matcher protocol, the matchable itself is used
- for interpolation patterns that did invoke the custom matcher protocol, the
value of the
value
property on the result object is used
For example:
class MyClass = {
static [Symbol.matcher](matchable) {
return {
matched: matchable === 3,
value: { a: 1, b: { c: 2 } },
};
}
};
match (3) {
when (${MyClass}): true; // matches, doesn’t use the result
when (${MyClass} with {a, b: {c}}): do {
// passes the custom matcher,
// then further applies an object pattern to the result’s value
assert(a === 1);
assert(c === 2);
}
}
or
match ("foobar") {
when (/foo(.*)/ with [, suffix]):
console.log(suffix);
// logs "bar", since the match result
// is an array-like containing the whole match
// followed by the groups.
// note the hole at the start of the array matcher
// ignoring the first item,
// which is the entire match "foobar".
}
Pattern combinators
Two or more patterns can be combined with or
or and
to form a
single larger pattern.
A sequence of or
-separated patterns have short-circuiting "or"
semantics: the or pattern matches if any of the nested
patterns match, and stops executing as soon as one of its nested
patterns matches. It introduces all the bindings introduced by its
nested patterns, but only the values from its first successfully
matched pattern; bindings introduced by other patterns
(either failed matches, or patterns past the first successful match)
are bound to undefined
.
A sequence of and
-separated patterns have short-circuiting "and"
semantics: the and pattern matches if all of the nested
patterns match, and stops executing as soon as one of its nested
patterns fails to match. It introduces all the bindings introduced
by its nested patterns, with later patterns providing
the value for a given binding if multiple patterns would introduce
that binding.
Note that and
can idiomatically be used to bind a matchable and
still allow it to be further matched against additional patterns.
For examle, when (foo and [bar, baz]) ...
matches the matchable
against both the foo
identifier pattern (binding it to
foo
for the RHS) and against the [bar, baz]
array pattern.
Bindings introduced by earlier nested patterns
are visible to later nested patterns in the same combined pattern.
(For example, (a and ${console.log(a)||a})
) will bind the matchable to a
,
and then log it.)
(Note: the and
and or
spellings of these operators are preferred by the champions group,
but we'd be okay with spelling them &
and |
if the committee prefers.
Parenthesizing Patterns
The pattern syntaxes do not have a precedence relationship with each other. Any
multi-token patterns (and
, or
, ${...} with ...
) appearing at the same
"nesting level" are a syntax error; parentheses must be used to to specify their
relationship to each other instead.
For example, when ("foo" or "bar" and val) ...
is a syntax error; it must be
written as when ("foo" or ("bar" and val)) ...
or when (("foo" or "bar") and val)
instead. Similarly, when (${Foo} with bar and baz) ...
is a syntax error; it
must be written as when (${Foo} with (bar and baz)) ...
(binding the custom
match result to both bar
and baz
) or when ((${Foo} with bar) and baz) ...
(binding the custom match result to bar
, and the original
matchable to baz
).
is expression
Refers to the expr is pattern
expression. Evaluates to a boolean to indicate if the LHS matches the RHS.
Possible future enhancements
async match
If the match
construct appears inside a context where await
is allowed,
await
can already be used inside it, just like inside do
expressions.
However, just like async do
expressions, there’s uses of being able to use
await
and produce a Promise, even when not already inside an async function
.
async match (await matchable) {
when ({ a }): await a;
when ({ b }): b.then(() => 42);
default: await somethingThatRejects();
} // produces a Promise
Nil pattern
match (someArr) {
when ([_, _, someVal]): ...
}
Most languages that have structural pattern matching have the concept of a "nil matcher", which fills a hole in a data structure without creating a binding.
In JS, the primary use-case would be skipping spaces in arrays. This is already covered in destructuring by simply omitting an identifier of any kind in between the commas.
With that in mind, and also with the extremely contentious nature, we would only pursue this if we saw strong support for it.
Default Values
Destructuring can supply a default value with = <expr>
which is used when a
key isn’t present. Is this useful for pattern matching?
Optional keys seem reasonable; right now they’d require duplicating the pattern
like ({a, b} or {a})
(b
will be bound to undefined in the RHS if not present).
Do we need/want full defaulting? Does it complicate the syntax to much to have arbitrary JS expressions there, without anything like wrapper characters to distinguish it from surrounding patterns?
This would bring us into closer alignment with destructuring, which is nice.
Dedicated renaming syntax
Right now, to bind a value in the middle of a pattern but continue to match on
it, you use and
to run both an identifier pattern and a
further pattern on the same value, like when(arr and [item]): ...
.
Langs like Haskell and Rust have a dedicated syntax for this, spelled @
; if we
adopted this, the above could be written as when(arr @ [item]): ...
.
Since this would introduce no new functionality, just a dedicated syntactic form for a common operation and some amount of concordance with other languages, we’re not pursuing this as part of the base proposal.
Destructuring enhancements
Both destructuring and pattern matching should remain in sync, so enhancements to one would need to work for the other.
catch
Integration with Allow a catch
statement to conditionally catch an exception, saving a level of
indentation:
try {
throw new TypeError('a');
} catch match (e) {
if (e instanceof RangeError): ...
when (/^abc$/): ...
default: do { throw e; } // default behavior
}
Chaining guards
Some reasonable use-cases require repetition of patterns today, like:
match (res) {
when ({ pages, data }) if (pages > 1): console.log("multiple pages")
when ({ pages, data }) if (pages === 1): console.log("one page")
default: console.log("no pages")
}
We might want to allow match constructs to be chained, where the child match construct sees the bindings introduced in their parent clause, and which will cause the entire parent clause to fail if none of the sub-classes match.
The above would then be written as:
match (res) {
when ({ pages, data }) match {
if (pages > 1): console.log("multiple pages")
if (pages === 1): console.log("one page")
// if pages == 0, no clauses succeed in the child match,
// so the parent clause fails as well,
// and we advance to the outer `default`
}
default: console.log("no pages")
}
Note the lack of matchable in the child (just match {...}
), to
signify that it’s chaining from the when
rather than just being part an
independent match construct in the RHS (which would, instead, throw if none of
the clauses match):
match (res) {
when ({ pages, data }): match (0) {
if(pages > 1): console.log("multiple pages")
if(pages === 1): console.log("one page")
// just an RHS, so if pages == 0,
// the inner construct fails to match anything
// and throws a TypeError
}
default: console.log("no pages")
}
The presence or absence of the separator colon also distinguishes these cases, of course.
or
on when clauses
There might be some cases that requires different when + if
guards with the same RHS.
// current
match (expr()) {
when ({ type: 'a', version, ...rest }) if (isAcceptableTypeVersion(version)):
a_long_expression_do_something_with_rest
when ({ kind: 'a', version, ...rest }) if (isAcceptableKindVersion(version)):
a_long_expression_do_something_with_rest
}
Today this case can be resolved by extracting a_long_expression_do_something_with_rest
to a function,
but if cases above are very common, we may also allows or
to be used on the when clause,
and the code above becomes:
// current
match (expr()) {
when ({ type: 'a', version, ...rest }) if (isAcceptableTypeVersion(version))
or when ({ kind: 'a', version, ...rest }) if (isAcceptableKindVersion(version)):
a_long_expression_do_something_with_rest
}