I wrote this alternative proposal to maybe inspire somebody to take the pattern matching proposal in a different direction. I initially wanted to post it in github issues, but looks like they're busy with rewriting the proposal once again and I don't want to spam.
Problems with the existing pattern matching proposal:
- Doesn't interact with existing patterns. Invents new pattern syntax with completely different semantics.
- Adds separate constructs which use only the new pattern syntax while leaving existing ones without the new featurs.
- Can't be split into separate proposals. Most of features are tightly coupled with each other.
- Probably is never gonna get accepted because it's so big.
ECMAScript Simple Pattern Matching Proposal
Let's add pattern matching to ECMAScript by introducing a few small features and syntax additions.
Every feature presented here adds a bit of useful functionality which can be used on its own and can even be split into a separate proposal.
The general idea is to
- Take existing patterns (let/const bindings, function parameters, etc) and expand their capabilities by allowing new kinds of pattern syntax.
- Add a new syntax to the
switch
statement which matches using any pattern (in addition to the existingcase
statement which matches by a single value)
Already existing pattern syntax
There's already some pattern syntax which asserts the type of a value and otherwise throws an error. This syntax would work seamlessly with new switch
additions. Consistency!
Object pattern
Asserts that the value is an object, otherwise throws an error.
const { a, b: c, d = 5, ...rest } = obj;
Array/iterable pattern
Asserts that the value is iterable, otherwise throws an error.
const [a, b, c = 3, ...rest] = arr;
New pattern syntax
The following are additions to the pattern syntax. They will work in existing places where patterns are used and in the new switch
syntax. All new syntax is just an example and alternative syntax can be proposed.
Literals in patterns
Allow literals in patterns which act like a value assertion. If the matched value isn't equal to the one provided in the pattern an error is thrown. Allowed are string, number and boolean values and null.
// Throws an error if the response was not ok or status wasn't 200
const { ok: true, status: 200, statusText } = await fetch("/");
// Throws an error if the first segment of path isn't "root"
const ["root", ...rest] = path.split("/");
// Simply assert that status is "success", otherwise throw
const "success" = status
TODO: currently an
undefined
literal would instead try to bind the value to a variable namedundefined
. It fails in most cases, but works in function parameters. Possible solution: keywordvoid
matchesundefined
.
Not-undefined pattern
Checking for property existence is one of the most common applications of pattern matching. Unfortunately the current behavior of patterns is that when a property is missing, the whole pattern still matches and the binding is set to undefined. This necessitates a syntax which would change that behavior. It should be concise since it probably will be used often.
Let's add a pattern which asserts that the value is not undefined. The syntax shall be a unary postfix !
after a sub-pattern.
It changes the subpattern so that it only matches if the matched value is not undefined. In other words, it adds an additional requirement to the pattern, so it works kinda like and-pattern automatically (see below).
A shorthand for destructured object properties shall be supported.
// Throws an error if the function isn't called with a user ID
function getUser(userId!) {
// Will throw early if user's name or age is missing.
const { name: firstName!, age! } = users[userId];
}
Or pattern
Add a pattern which matches if at least one of the sub-patterns match. If one subpattern matches, the rest aren't evaluated. If none of the sub-patterns match, an AggregateError is thrown.
The syntax shall be a binary |
operator where operands are sub-patterns.
If a variable binding exists in only one of the sub-patterns, it will be declared, but will be undefined if other sub-pattern matches.
If the same iterable is iterated in multiple sub-patterns, it will be iterated only once. The result of the iteration will be cashed and reused in the other sub-patterns.
// Throws an error if the response wasn't successful.
const { status: 200 | 201 } = await fetch("/");
// Retrieves user's name from either first or last name property.
// Throws if neither is defined.
const { firstName: name! } | { lastName: name! } = user;
New switch
syntax
Add new syntax to the switch
statement to make use of the pattern syntax.
const
and let
cases
Allow const
and let
in addition to case
and default
in switch
statements.
The statement matches if the given pattern doesn't result in one of the errors described above. Only the errors originating from failed patterns are caught and treated as failed match. If, for example, an object getter throws a custom exception during match, it won't be caught automatically.
If an iterable is iterated in multiple cases, it will be iterated only once and it's results will be cached and reused in the other cases.
switch (response) {
const { ok: true }:
console.log(await response.json());
break;
const { ok: false, status, statusText }:
console.error(`Error: ${status} ${statusText}`);
break;
}
TODO: is this distinguishable from a regular const or let statement? Maybe add a keyword:
case const
andcase let
?
break
less cases
Add a syntax similar to an arrow function which allows omitting the break
statement. The syntax shall use thin arrow (->
) to distinguish it from a function syntax, but also to suggest similar usage.
switch (payload) {
const { type: "add", value } -> state.push(value);
const { type: "remove", index } -> state.splice(index, 1);
const { type: "update", index, value } -> {
state[index] = value;
}
}
TODO: Can semicolons be optional?
switch
as an expression
Either wait until the do-expressions proposal is implemented or propose a similar feature just for switch. The syntax should align with the do-expressions proposal.
Let's assume that all you have to do is put a switch in expression position to enable this behavior.
return (
<div>
{switch (request) {
const { status: "loading" } -> <Spinner />;
const { status: "success", data } -> <UserProfile data={data} />;
const { status: "error", error } -> <Alert message={error} />;
}}
</div>
);
Summary
These additions encompass most of the features of the popular ts-pattern
library.
One advantage of bringing these features to the language is that they additionally allow control flow (like break
and return
) to work from inside the match branches.
Possible future improvements
These are just ideas which could be added in the future proposals or not.
And pattern
Add a pattern which matches if all of the sub-patterns match. If at least one of the sub-patterns fails, the whole pattern fails and throws an error.
// Asserts that the options parameter is an object
function init(options & {}) {}
// Asserts that the array has exactly 2 elements and binds them to x and y
const { length: 2 } & [x, y] = point;
let
and const
in if
and while
statements
Allow let
and const
in if
and while
statements to test patterns and bind variables at the same time.
Similar feature exists in Rust.
if (const { status: 200, statusText } = await fetch("/logout", { method: "POST" })) {
console.log("Success:", statusText);
}
TODO: What about
if (let pat = expr && let pat = expr)
? Rust doesn't support it neither, but makes it a syntax error for future compatibility.
Pattern guards
Allow syntax at the end of any pattern which allows to add any condition to the pattern. The syntax shall be pattern if (expr)
.
Similar feature exists in Rust.
switch (response) {
const { status } if (status >= 200 && status < 300) -> console.log("Success");
const { status } if (status >= 400 && status < 500) -> console.warn("Client error");
default -> console.error("Unexpected response");
}
TODO: Should it be allowed in if-let
and if-const
as well? How about regular let
and const
?
typeof
pattern
A pattern which allows matching on value's type. The syntax shall be a prefix typeof
operator followed by a sub-pattern which matches the string value representing the type: typeof <pattern>
switch (value) {
const typeof "string" -> console.log("It's a string");
const typeof "number" -> console.log("It's a number");
default -> console.log("It's something else");
}
function add(a & typeof "number", b & typeof "number") {
return a + b;
}
// Assigns `typeof value` to `valueType` in a weird way
const typeof valueType = value
instanceof
pattern
A pattern which asserts that the value is an instance of a given class. The syntax shall be a prefix instanceof
operator followed by an expression which evaluates to a class: instanceof <expr>
.
try {
doSomething()
}
catch (error) {
switch (error) {
const instanceof TypeError & { message } -> console.error("Type error:", message);
const instanceof RangeError & { message } -> console.error("Range error:", message);
default -> {
throw error;
}
}
}
Comparison patterns
A pattern which asserts that the value is greater, less or equal to a given value. The syntax shall be a prefix comparison operator followed by an expression. E.g. <= 123
.
switch (response.status) {
const >= 500 -> console.error("Server error");
const >= 400 -> console.warn("Client error");
default -> console.log("Success");
}
const { length: > 0 & <= 10 } = array;
Template literal pattern
A pattern which asserts that the value is a string which matches a given template literal. The holes in the literal are sub-patterns.
const `${user}@${domain}` = email;
switch (userCommand) {
const `/kick ${userId}` -> kickUser(userId);
const `/ban ${userId}` -> banUser(userId);
}
RegExp pattern
A pattern which asserts that the value is a string which matches a given regular expression.
switch (s) {
const /.*@.*\..*/ -> console.log("It's an email");
const /.*:\/\/.*/ -> console.log("It's a URL");
}
Let-else and const-else statements
Add a syntax to let
and const
which allows to do something else than throwing a default error. Variable declarations can be followed by an else
branch which runs when the pattern doesn't match. The else
branch can be a single diverging statement or a block which ends with a diverging statement (return
, break
, continue
or throw
).
Similar feature exists in Rust.
for (const child of element.children) {
const { tagName: "a", href } = child else continue;
console.log("Link:", href);
}
Exact length iterable pattern
Default array/iterable pattern currently only matches on the specified starting elements and ignores the rest. There could be a syntax which makes the pattern fail if there are more elements than expected.
// Must have at least 1 element and no more than 3
const [a!, b, c, ...void] = generator();
Custom matchers
Add @expr
syntax which evaluates expr and calls it's Symbol.match
method with the value. If it throws an error, the pattern fails.
if (const { length: @range(0, 10) } = str) {}
If the match method returns a value, it could be matched by a sub-pattern. Syntax is TODO.