Simpler pattern matching proposal

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

  1. Take existing patterns (let/const bindings, function parameters, etc) and expand their capabilities by allowing new kinds of pattern syntax.
  2. Add a new syntax to the switch statement which matches using any pattern (in addition to the existing case 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 named undefined. It fails in most cases, but works in function parameters. Possible solution: keyword void matches undefined.

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 and case let?

breakless 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.

1 Like

A few points after a quick skim:

  • Object destructuring doesn't assert it's an object, it asserts it's non-nullish - it only throws on null or undefined
  • a trailing ! would conflict syntactically with TypeScript, but more importantly, conceptually it wouldn't match the intuitive usage which is that it's null OR undefined, and in function arguments, there's three possibilities - "absent" is distinct from "undefined".
  • in const { status: 200 | 201 }, there's a few problems. one is that this would only allow for patterns that are not identifiers, and another is that | conceptually overlaps too much with the bitwise OR operator.
  • switch is a horrifically unintuitive and bug-prone construct and I would personally be opposed to any improvement of it. It needs a complete replacement, which is what the current proposal is.

I think it's fine and it should work the same way in pattern matching.

Seems like my examples with this syntax currently fail to parse on ASTexplorer.

Also, your previous point made me realize that this syntax could also be pattern & {}. In the end I don't care which syntax will be choosen anyways.

I conceptualized { foo! } as shorthand for something like { foo = throw MatchError() } (aka shorthand for default value where the value is a throw expression). That's why I thought it should reject only undefined, but it's not that important to me.

I rarely ever needed such a pattern and I used a lot of ts-pattern library and Rust.

Also it could be added later as @var or ==var. See hidden "possible future improvements" in OP.

Might as well be || or or like in the current proposal. I just think | looks nicest and it overlaps with TS union syntax which is good.

I agree. I just tried to come up with something that would require minimal additions to the language.

TypeScript doesn't allow non-null assertions in destructuring patterns. It seems logical, and the feature was requested, but it's currently rejected

I love this proposal, the syntax feels quite natural!

Some quibbles and ideas:

  • "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." - that will be potentially awkward if the patterns have different lengths. You'd need to keep the iterators open until a matching pattern is found, and only the close all iterators that were opened during any of the previous matching attempts. It might be simpler to iterate the iterable value multiple times. Just like a getter should be evaluated multiple times when the same property is matched by multiple patterns.
  • Literal patterns should allow bigints and undefined
  • Literal patterns for symbols are impossible :-(
  • As @ljharb indicated, & {} has the same meaning as !
  • const typeof "string" reads weird. I get its usefulness in other patterns, but you should've chosen an example that cannot be simplified to switch (typeof value) { case "string" -> …
  • const instanceof RangeError & { message } = error doesn't read well. Can we have const { message } instanceof RangeError = error;?
  • template literal patterns seem too powerful - and at the same time not powerful enough for parsing, since they can only do .startsWith and .indexOf. Maybe rather wait for custom matchers or extractors.

However, I wonder mostly how these patterns work with the full destructuring syntax with aliases and default initialisers.

  • Would it be const { message: msg! } = error or const { message!: msg } = error?
  • How can I still extract the property value into a variable after matching it with literal pattern alternatives? For objects I could do const { status: 200 | 201, status } = response, but for iterable destructuring it seems impossible.
  • Would default initialisers be evaluated before or after matching the result value against a pattern? I presume that (with current evaluation order) const { status: 200 = 200 } = response would match an object where the .status property is undefined.

Maybe it would be prudent to use a guard/matcher syntax for everything more complicated than literal patterns (where creating a binding isn't useful anyway). I could imagine something like

const { name: firstname @!=(null), age @!=(null) } = users[userId];
const { status @===(200, 201) } = response; // still binds `status` variable
const [x, y] @{ length: 2 } = point;
const { status } @if(status >= 200 && status < 300) = response;
const myString @typeof("string") = value;
const myNumber @typeof("number") = value;
const myStringOrNumber @typeof("string", "number") = value;
const { message } @instanceof(TypeError, RangeError) = error;
const { status @>=(500) } = response;
const { length @>(0) @<=(10) } = array;
const email @matches(/.*@.*\..*/) = s;
const { length @inRange(0, 10) } = str;
const [x, y] @hasLength(2) = point;

Basically use

  • @ with relational operator (including typeof and instanceof) and a set of arguments to test for any of them to match
  • @if with boolean expressions to match arbitrary conditions (with the prior bindings already in scope)
  • @ with arbitrary identifier to run a user-defined function (which returns boolean value)
  • @ with another object pattern or array pattern to apply a second pattern - not sure if useful

Any such @-pattern should be usable in the grammar

  • after any other target pattern (x @…, [x @…], [x = def @…], [x @… = def], {x: y @…}, {x: y = def @…}, {x: y @… = def})
  • including in a chain with multiple @-patterns (x @… @…) where all will need to match
  • even in place of any target pattern, leading to no binding being created (@…, [@…], {x: @…}) - although that would collide with potential syntax for extractors.

This is how the original proposal works and I think it's fine.

undefined is already allowed in patterns so it wouldn't be backwards compatible.

The first one. The latter one would be syntax error.
{ message! } is just a shorthand for { message: message! }.

You could do { status: status & (200 | 201) } if the and-pattern will be included in the proposal. I'm striving for something minimal tho.

Good question. I think default initializers must apply first and then the inner pattern must match on the possibly defaulted value. That's because you can already write a pattern like { options: { foo } = { foo: "bar" } }.

More on custom matchers

I included the @customMatcher syntax only because people really want it for some reason. I personally never needed much more than what's in my main proposal. Your examples show very well why I don't like it. This syntax looks very alien and not like JS at all.

Also with my extra proposal the exact syntax would be { prop: variable & @foo & @bar } and the ones that use a keyword don't need the @ because they are different kinds of patterns.