tc39 / proposal-pattern-matching

Pattern matching syntax for ECMAScript
https://tc39.es/proposal-pattern-matching/
MIT License
5.44k stars 89 forks source link

We should reframe the approach here #281

Closed codehag closed 5 months ago

codehag commented 2 years ago

Edited Note: I've injured my wrist quite badly. Some context is missing here, it will be filled in later (in a week or so hopefully).

As mentioned on matrix -- and in past meetings of the champion group, i believe this proposal is too complex in its current form, and as a champion i have raised this and the epics concept as a way to alleviate this. When this appeared on the agenda for stage 2, i was not informed. I have rushed to put this together, it is only an idea, or a write up of one, of how this could be done: https://github.com/codehag/pattern-matching-epic

and here, is a suplimentary document. https://docs.google.com/document/d/1dVaSGokKneIT3eDM41Uk67SyWtuLlTWcaJvOxsBX2i0/edit

There are many forms of simplification and layering that are possible here. this is not the only one. Perhaps this is too fine grained. As i wasn't informed of this moving to stage 2 (likely because i was ill at the time), its clear that my contribution has been small so i've also stepped down as a champion.

sorry i can't say more now as its quite hard to type.

ljharb commented 2 years ago

Custom matchers could never be added later if they’re not added in the first version, including the ability to modify the pass-through value, which is necessary for RegExps.

codehag commented 2 years ago

thats not true.

ljharb commented 2 years ago

I’d love to hear more - once the ability to pass an arbitrary expression is present, we wouldn’t be able to add custom matchers to builtins, because code on the web could depend on ${Map} eg doing an === Map comparison, rather than “is this an instance of a Map”

Jack-Works commented 2 years ago

I'm gonna paste my reply on Matrix.

Assignment, test, aliasing

I never think of this problem before because I'm very familiar with the status quo proposal, but once Yulia pointed this out, I agree this is a serious problem we need to reconsider. Even with today's JavaScript, I am sometimes confused in the deconstruction alias syntax { a: b } which is the binding I can use? If we add one more overload to the { ... } it indeed will make things much harder to read.

let val when Option.isSome

At the first glance, this makes me think of @rbuckton 's unapply proposal. I'm much in favor of that form in the past, let Option(val) = expr. But after reading the whole document, I'm worried about if unapply syntax could cover all the abilities in Yulia's document.

Here is a feedback from a normal JavaScript developer:

I don't know too much about those concern (test & assignment, ...), but as a normal user, except isOk and maybeRetry.bind(this). I agree Yulia's version is much more readable.

rkirsling commented 2 years ago

This is really cool! I think challenging ourselves to reuse existing structures is an extremely worthwhile reframing of things.

In general, the layered proposal idea sounds like a fantastic antidote toward biting off too much unnecessarily. (We should definitely bikeshed a name other than "epic", as that sounds like JIRA and makes me want to quit tech forever 😄 but I think something like "layered proposal" would be descriptive.)

A few specific comments:

Thanks for doing such a thorough analysis. I hope your wrist heals quickly!

(FWIW, I also don't think you'd need to step down if it's purely due to lack of recent participation; I've been even more distant myself these past months, and it'd be a loss to not have your voice in this context.)

rbuckton commented 2 years ago

I have read through the proposal and I'll admit I need some time to work through the design changes and put together all of my impressions and concerns, though I'd like to list a few things I have some issues with at first glance.

I'm a bit concerned about the let ... when ... syntax based on my investigation into extractor objects as an upcoming proposal. In Scala, an extractor is part of the pattern matching mechanism, and a failure to match a pattern when initializing a variable would result in an exception, not a default:

// if customer is a match, populates 'name'
// if customer is not a match, throws 'scala.MatchError'
val CustomerID(name) = customer; 

For the extractors proposal, I am considering the same approach. For a variable declaration, a failed match would result in an error since there is no escape hatch for a refutable match. While for match, you are able to declare alternate match legs or a default for an irrefutable match.

For the extractor objects proposal, this example would instead be something like:

const IsOk = { [Symbol.matcher]: response => response.status === 200 };

// declaration
let IsOk{ body } = response; // if not ok, throws an error

// match
match (response) {
  IsOk{ body }: ...; // if not ok, try next leg
  ...
}

I'm hoping that extractors will remove some of the need for with, and will better dovetail with ADT Enums and destructuring, but I don't see that being the case with let...when....


I'm also not sure what let ... when ... gives you over let ... = ... ? ... : ... in your example:

let { body } when isOk(response);

...

let { body } = isOk(response) ? response : {};

The second example already gives you more control over the non-matching case. It's also not clear how let ... when ... would choose an appropriate default value. If you write let [x, y] when f(obj) will it change the default to [] instead of {}, or will it throw because the default isn't iterable?

What about cases like this:

const x = Math.floor(Math.random() * 1000);
const y when isEven(x);

What is the expected value of y when x is odd?


Please, take whatever time you need to respond.

Jack-Works commented 2 years ago

Custom matchers could never be added later if they’re not added in the first version

Yulia does not mean we make an MVP version (like class), it writes we should design them as many different small parts (and ship them at once so it won't be another class design) that can work well together and can work on their own.

I have the same feeling about the Pipeline proposal, which can be split into Partial Function Application (f(?, 2) returns a higher order function) and F# style pipeline. In this way, Partial Function Applications can be useful on their own, and the F# style pipeline can get rid of the topic operator.

I see the same idea in Yulia's design so I like it.

We should definitely bikeshed a name other than "epic", as that sounds like JIRA and makes me want to quit tech forever 😄

LOL I agree

but then there's also do { switch {} }

Oh No, please don't. See my reply at https://github.com/tc39/proposal-do-expressions/issues/75

relation to unapply

I noticed that there is something that @rbuckton's unapply cannot do but Yulia's version can.

// unapply
if (let Some(val) = expr) {}
// when
if (let val = expr when isSome) {}

This is both two designs can do, but the following is not

// unapply
if (how to do this?) {}
if (let url = response when { status: 301 } {}
//                     ^ pattern matching here!
codehag commented 2 years ago

Some answers to questions, this time with a bit more detail:

Custom matchers could never be added later if they’re not added in the first version, including the ability to modify the pass-through value, which is necessary for RegExps.

and

I’d love to hear more - once the ability to pass an arbitrary expression is present, we wouldn’t be able to add custom matchers to builtins, because code on the web could depend on ${Map} eg doing an === Map comparison, rather than “is this an instance of a Map”

Jordan and I discussed this in a call -- Custom matchers can be a dependency on which pattern matching is built. This would mean that we do base matchers and custom matchers before we introduce pattern matching syntax. This was my suggestion, and my answer to how custom matchers can be handled separately. In addition, an alternative splitting is one @ljharb suggested: we can split by introducing ${Builtin} at a later point in time. I am not opinionated in how this is done.

To @rbuckton

I'm also not sure what let ... when ... gives you over let ... = ... ? ... : ...

I should have been clearer here: this isn't a perfect analogy. The only thing set here is if let ... when ... is pattern matched, then it is bound. Otherwise -- we have options in what can happen. My proposal was to be unobstructive and "pass through" an undefined value on failure. Throwing an error is another option. None of this is in stone, these are ideas.

What about cases like this:

const x = Math.floor(Math.random() * 1000);
const y when isEven(x);

What is the expected value of y when x is odd?

in my original thinking: undefined. But this is open to discussion. The goal was to get us thinking of the match clause as a base of this proposal, rather than patterns or the match statement itself. Using the match clause as the base allows us to first work with base matchers, then custom matchers, then syntax.

We should definitely bikeshed a name other than "epic", as that sounds like JIRA and makes me want to quit tech forever 😄

happy with "layered proposal", thats a good suggestion

Yulia does not mean we make an MVP version (like class), it writes we should design them as many different small parts (and ship them at once so it won't be another class design) that can work well together and can work on their own.

This is largely correct: the idea is to fix our tendency to do versioned proposals (like classes), and instead have a clear story for a more complex proposal like this one, that allows room for each piece to breath and be considered carefully. It brings necessary scrutiny to the constituent parts of the proposal. For me, this exercise was educational. It is discouraging to have it always said that this is impossible, and is likely why i didn't attempt it before. But this reaction is understandable -- we've been burnt by MVPs before. Anyway, we are trying to help eachother here, my goal is not to throw you all off track or ask that you change everything. Due to a long day at work i mixed two separate concepts -- my identification of a potential syntax issue, and the question of "how might we split this while preserving the key parts".

As for shipping: sometimes we conflate shipping with stage 3. I would say stage 3 is fine, even encouraged as it will allow us to write tests and start implementing (potentially informing the design). I would also say -- shipping the proposal as a whole or piece meal is at the discretion of the champions, and they can communicate that. we have precedent for this.

codehag commented 2 years ago

I've updated the title to hopefully be more about the reframe, rather than "simplification" -- as simplification is really about decoupling, not necessarily feature removal.

ljharb commented 2 years ago

@codehag and I had a productive call last night, and I've removed pattern matching from this week's agenda now that I more fully understand both her and the SpiderMonkey team's feedback.

The tl;dr as I understood it:

  1. the proposal would be better designed (by being less overwhelming to understand to delegates and implementors, and thus receiving more scrutiny and better feedback) if it were conceptually split into different layers, regardless of whether they ship simultaneously or not.
  2. The champion group should reevaluate whether bindings and patterns should be conflated (while still allowing for sugar for the common case to avoid repetition) to avoid user confusion from varied approaches to reading and understanding the syntax.
  3. @codehag personally reads the bindings as similar to destructuring assignment, which in the case of separation would mean bindings come first, before patterns; I personally read them as similar to a destructuring in a function or catch block signature, which would mean patterns come first, before bindings - while subjective matters of perspective may never be resolved, we need to discuss and explore this thoroughly to ensure we're designing the best possible language feature.

In early August, I'll reach out to champions (and @codehag) to schedule a meeting to discuss the proposal. With any luck, we'll be able to present a compelling update in September that will preemptively address this feedback, and consider if we're again ready for stage 2 advancement after processing feedback from that plenary.

littledan commented 2 years ago

I'm happy about the decision to take time to discuss this proposal further.

About custom matchers coming later: I really want custom matchers to happen, but I could definitely imagine them coming post-MVP. All we'd need to do is say, if you use a primitive as a matcher, it checks by === (or similar), and if you use an object, an exception is unconditionally thrown. If someone wants to compare by ===, they can do so in an if guard. This would significantly reduce the forward compatibility risk of adding custom matchers later.

treybrisbane commented 2 years ago

Dropping custom matchers from the initial iteration would be a significant reduction in scope, for sure. Sure it would make pattern matching less powerful, but it would still be extremely useful.

Notably, doing that would give @rbuckton more time to progress the Extractors proposal, which could potentially then serve as a base for custom matcher syntax (an approach that has precedent in Scala and some other languages).

sarahghp commented 2 years ago

Thanks so much for pulling this example together, @codehag.

In terms of the general approach, I especially admire the way that by looking at the proposal in terms of layers, opportunities arise to decompose changes into elements that are larger than the proposal but in keeping with the grain of the language. The way the base case and implicit values expand into catch guards, for instance.

Regarding this specific proposal, while it is certainly not required that layers be implemented slowly, I would like to reiterate my comment from the previous plenary that doing so allows for our understandings of usage to deepen as functionality is used. In nearly every plenary, I hear people talk about "mistakes" that we are now stuck with, and it seems like letting functionality ripen will help avoid some mistakes of over- or mistaken design.

Even more specifically, I find the Fixing switch section the most compelling and really the core of the proposal. While other, more complex uses of matching can be conceived, this is the main existing problem; solutions built out of this will likely be the most robust, it seems to me.

I do have a bit of concern on waiting for the extractors proposal. Suddenly making assignment throw instead of resulting in undefined, while currently fashionable, may not be desirable long-term, especially in the sense of creating mixed metaphors and countering reasonably stable expectations.

Anyways, thanks again! Can't wait to see where this all goes.

rbuckton commented 2 years ago

I wrote up https://gist.github.com/rbuckton/e49581c9031a73edd0fce7a260748994 to show how the Extractors proposal I have been working on could potentially provide the benefits from @codehag's proposal by providing counter-examples. I quite like the idea of breaking down pattern matching into pieces that are composable, though I differ from pattern-matching-epic in a number of syntactic choices.

codehag commented 2 years ago

Thanks @rbuckton -- this was an interesting read. Importantly, I don't care too much about the exact syntax, so I am very open to counter proposals. A few thoughts:

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

this looks pretty interesting. I am also very interested in how this is expressed in the for loops:

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

I very much like the consistency.

for the match statement:

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

I would have expected (but understand why this is impossible):

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

Something I like about the strategy overall: This feels like it falls in line with tagged template functions, ie isOk\some string``. What if this was ubiquitous in the language? that is an interesting question.

A draw back: It is unfortunate it cannot be applied to arrays. However in the current form I think this doesn't work. It is too close to function invocation. At least, in my opinion right now -- maybe this could be like function arguments. But then this must apply consistently.

This is where it breaks down for me. It results in too much inconsistency. I am open to being shown that it is consistent though. Otherwise, my preference is for the current proposal with meaningful left and right hand sides.

Still, it is fun to play with.

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

Not bad at all in my opinion. I was thinking along these lines when I wrote this up, but it felt too radical:

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

That said there may be a happy middle, which may work for you as well and was mentioned by jordan. We are close to the existing with syntax at this point. So why not:

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

But this needs more thought. It still moves the assignment to the right, which is hard to find quickly. But this still aligns with my push to reuse existing language constructs.

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

Thats pretty neat. I hadn't thought about the let in that position. It does sort of drop my goal of having the right hand side always be a test for patterns though.

regarding is and other positions for patterns -- I didn't have much time to think about it at the time, and I don't have strongly held opinions. Open to discussion. I would eagerly discard it to be honest, it was mostly to enable the pattern matching to be used elsewhere.

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

Point taken, though the "does not work well " is intentional. Thus, the counter point: This is still easier to read and that should be our goal:

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

This isn't all that longer than the current proposal, and we remove the confusion introduced by the aliasing. I think this is significantly easier to read. The argument "oh but the body of the match is really big" doesn't work here -- this is why functions exist. We have a fully featured language. The ability to write things in one line is not the benefit it is being made out to be here.

As an aside, by the way, this is why, in my opinion, we should not be letting go of fall through. For instance:

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

So we could make it possible, but the allergy to switch has made it impossible. That said, my preference is for the example above rather than this one.

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

good point, can be a syntax error possibly

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

Consider:

const url = "http://xyz"
match (x) {
  when ({ status: 500, destination: url }): handle(x, url)
}

If you were not familiar with this proposal, what is going on here? given that aliasing is a problem syntax for developers, and even committee members, what is the intention? Consider, instead:

const url = "http://xyz"
match (x) {
  let { destination: url }  when ({ status: 500, destination: url }): handle(x, url)
}

here the intention is clear(er). Or as much as can be so given the mistake we made with destructuring and aliasing. This also opens up the right hand side of the when statement to matchers. This makes it consistent. The right hand side of a : in a when always tests.

let-when, on the other hand, only allows destructuring without :.

Let me know if there were other segments of your document you want to have more attention on. I was just picking out the bits that I found interesting.

rbuckton commented 2 years ago

@codehag: Thank you for your thorough review.

I would have expected (but understand why this is impossible):

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

While [] might seem more consistent when comparing to something like tuples or array destructuring, the Extractor concept is partially modeled on symmetry with construction/application in Scala, with an Extractor representing the "unapplication":

import scala.util.Random

object CustomerID {
  def apply(name: String) = s"$name--${Random.nextLong}"

  def unapply(customerID: String): Option[String] = {
    val stringArray: Array[String] = customerID.split("--")
    if (stringArray.tail.nonEmpty) Some(stringArray.head) else None
  }
}

val customerID = CustomerID("Nico") // application
val CustomerID(name) = customerID   // unapplication
println(name) // prints: Nico

Since an argument list in JavaScript is very Array-like (especially in strict mode), using () for both construction and extraction seems fairly consistent to me.

Something I like about the strategy overall: This feels like it falls in line with tagged template functions, ie isOk`some string`. What if this was ubiquitous in the language? that is an interesting question.

I have been considering this as well in relation to ADT-enums, and as a general purpose construction mechanism. For example, it might be useful to be able to perform initial assignments for classes, structs, Map, etc.:

// class construction
const point = new Point{ x: 10, y: 20 };
const map1 = new Map{ a: 1, b: 2 };
const map2 = new Map(map1) { c: 3, d: 4 };

// enum/struct/value allocation
const message = Message.Resize{ height: 100, width: 200 };

// css-in-js
const styles = CSS{
    border: "solid black 1px",
    background: "green",
};

How that would be accomplished is still up in the air. For tagged templates, we invoke the function with a specially crafted argument list. We could either do the same here, or introduce a symbol-named mechanism to reduce overload friction, i.e.:

// used with `new F{ ... }` or `new F(){ ... }`
Map[Symbol.propertySetConstruct] = function (propertySet, ...args) {
    const map = new Map(...args);
    for (const [key, value] of Object.entries(propertySet)) {
        map.set(key, value);
    }
    return map;
}

const CSS = {
    // used with `F{ ... }` or `F(){ ... }`
    [Symbol.propertySetCall](propertySet) {
        const styles = ...;
        ...;
        return styles;
    }
};

That said there may be a happy middle, which may work for you as well and was mentioned by jordan. We are close to the existing with syntax at this point. So why not:

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

Introducing an as keyword here would break the intended symmetry with declaration, construction, and destructuring that I am hoping to achieve in tandem with an updated enum proposal that supports ADT-style enums:

enum Option of ADT {
    Some{ value }, // declaration
    None
}

// construction
const opt = Option.Some{ value };

// destructuring
const Option.Some{ value } = opt;

match (opt) {
    when(Option.Some{ value }): ...; // pattern matching with `match`
}

if (opt is Option.Some{ value: 1 }) ...; // pattern matching with `is`

I'm also concerned about potential collision or confusion with the as typecast syntax present in TypeScript and the Stage 1 Type Annotations proposal, especially in conjunction with something like an is infix operator. Something like x is Foo as { y } becomes increasingly confusing at a glance.

Introducing as would also complicate nested pattern matching:

// with 'as'
match (x) {
    when (Option.Some as { value: Message.Move as { x, y } }): ...;
}

// without 'as'
match (x) {
    when (Option.Some{ value: Message.Move{ x, y } }): ...;
}

The as just seems to add more visual noise that I don't believe is warranted.

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

Point taken, though the "does not work well " is intentional. Thus, the counter point: This is still easier to read and that should be our goal:

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

This isn't all that longer than the current proposal, and we remove the confusion introduced by the aliasing. I think this is significantly easier to read. The argument "oh but the body of the match is really big" doesn't work here -- this is why functions exist. We have a fully featured language. The ability to write things in one line is not the benefit it is being made out to be here.

As an aside, by the way, this is why, in my opinion, we should not be letting go of fall through. For instance:

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

So we could make it possible, but the allergy to switch has made it impossible. That said, my preference is for the example above rather than this one.

A number of the pattern matching champions have expressed the opinion that implicit fall-through is bad and should be avoided at all costs. I don't personally share this opinion, but even with fall-through I would argue that supporting logical patterns with duplicate bindings is consistent with the recent change to allow duplicate named capture groups in regular expression patterns. It also is potentially more efficient as it avoids re-evaluating custom matchers for each match leg.

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

Consider:

const url = "http://xyz"
match (x) {
  when ({ status: 500, destination: url }): handle(x, url)
}

If you were not familiar with this proposal, what is going on here? given that aliasing is a problem syntax for developers, and even committee members, what is the intention? Consider, instead:

const url = "http://xyz"
match (x) {
  let { destination: url } when ({ status: 500, destination: url }): handle(x, url)
}

here the intention is clear(er). Or as much as can be so given the mistake we made with destructuring and aliasing. This also opens up the right hand side of the when statement to matchers. This makes it consistent. The right hand side of a : in a when always tests.

I would be more partial to explicit binding declarations via inline let/const bindings:

const url = "http://xyz"
match (x) {
  when ({ status: 500, destination: let url }): handle(x, url)
}

I still find the let ... when ... syntax in match to be unnecessarily repetitive with respect to matching and then declaring bindings and would more than likely fall back to the let when syntax for almost all of my use cases. And in those cases, if I'm going to have a let keyword, I'd rather it be used to visually distinguish the actual binding in the pattern rather than be a prefix to the entire clause.

let-when, on the other hand, only allows destructuring without :.

This restriction concerns me as it explicitly puts let when in a position where it can't handle shadowing at all. This pushes me even more towards a preference for explicit inline bindings.

Let me know if there were other segments of your document you want to have more attention on. I was just picking out the bits that I found interesting.

My goal for Extractors is a capability that covers more than just pattern matching, though there is significant overlap. I'm also interested in their use as a general-purpose destructuring mechanism in binding patterns, including function parameters. For example, in my gist for Extractors, I have the following use case:

// A custom extractor to re-interpret a value as an Instant
const InstantExtractor = {
    [Symbol.matcher](value) {
        if (value instanceof Temporal.Instant) {
            // if the value is already an instant, return the value as a match.
            return { matched: true, value: [value] };
        }
        else if (value instanceof Date) {
            // if the value is a JS Date, convert it and return a match.
            return { matched: true, value: [Temporal.Instant.fromEpochMilliseconds(value.getTime())] };
        }
        else if (typeof value === "string") {
            // if the value is a string, parse it and return a match.
            return { matched: true, value: [Temporal.Instant.from(value)] };
        }
        else {
            // the value was not a match
            return { matched: false };
        }
    }
};

class Book {
    constructor({
        isbn,
        title,
        // Extract `createdAt` as an Instant
        InstantExtractor(createdAt) = Temporal.Now.instant(),
        InstantExtractor(modifiedAt) = createdAt,
    }) {
        this.isbn = isbn;
        this.title = title;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
    }
}

new Book({ isbn: "...", title: "...", createdAt: Temporal.Instant.from("...") }); // ok, already an instant
new Book({ isbn: "...", title: "...", createdAt: new Date() }); // ok, can convert from date
new Book({ isbn: "...", title: "...", createdAt: "..." }); // ok, can convert from string
new Book({ isbn: "...", title: "...", createdAt: {} }); // error, not a valid match

Whatever we end up for pattern matching, I'd like to ensure we are looking far enough ahead to other potential future capabilities for the language so that we avoid major inconsistencies or paint ourselves into a corner.

rbuckton commented 2 years ago

After taking another look at the Scala example above, it really does make me wish we already had ADT enums and Option, as that could make for a better result than the { matched, value } match result object, i.e.:

// given:
enum Option of ADT {
  Some(value),
  None
}

// custom matcher
const InstantExtractor = {
  [Symbol.matcher]: value => match(value) {
    when (Temporal.Instant): Option.Some([value]);
    when (Date): Option.Some([Temporal.Instant.fromEpochMilliseconds(value.getTime())]);
    when (String): Option.Some([Temporal.Instant.from(value)]);
    default: Option.None;
  }
}
theScottyJam commented 2 years ago

While I like the general idea of decomposing pattern-matching into multiple proposals, I'm worried about a couple of things based on the conversation thus far:

1. The foundational proposal in both of the proposed split-ups seem to be fairly week.

@codehag's version introduces this syntax in it's foundational, layer-1 proposal

const { body } when isOk(response);

I'll assume the when syntax causes all LHS bindings to be set to undefined when the RHS returns false. A more complete code example would then be this:

const { body } when isOk(response);
if (body === undefined) {
  ...
}

which really isn't that different from what's possible today (plus, today's solution is safer, as it'll correctly handle the case when the response is ok but the body is undefined).

if (!isOk(response)) {
  ...
}
const { body } = response;

In the end, I feel like this syntax shorthand hasn't provided any value to the language, and it won't be able to do so until we start layering on further proposals. The rest of the foundational proposal showed other ways we could extend this root idea by putting it into for loops or what-not, but none of these are that much of an improvement compared to the alternatives that are possible today.

There's also the option to make when throw when the pattern-matching fails, but it's also trivial in userland to define an isOk() function that throws when it's not ok, so you're still not adding much benefit to the language.

@rbuckton's extractor proposal is interesting once the future layers come on, but again, I feel the foundational proposal is lacking.

const isOk{ body } = response;

I assume isOk is implementing some sort of special protocol to make this work. This sounds like as much work as just doing this:

function isOk(response) {
  if (...) {
    throw new Error(...);
  }
  return response;
}

// elsewhere...
const { body } = isOk(response);

I think it's important to split this proposal up in ways that minimizes the amount of "we need to do ABC decision, because DEF and XYZ will need it like that down the line". Of course that sort of thinking will have to happen, as this is an epic we're talking about, so we're still wanting to think how it's constituents proposals interact with each other, but if we've having to do that for every single decision related to a sub-proposal, then we're worse of than where we started. For this reason, I think it's important for every proposal, especially the foundational proposal, to be able to stand on its own legs and carry its own weight, instead of relying on us looking "down the line" to see it's true value.

2. We're introducing new features into the pattern-matching epic that can't be changed or removed, unless we restructure the whole epic.

The const { body } when syntax is interesting, but what if it's not really for the JavaScript language? Extractors are cool, but, what if they're really not for the JavaScript language? I worry about hinging the entire subdivision of pattern-matching onto concepts like this that haven't yet been extensively discussed. If, in the end, we decide we don't want that sort of syntax, or we want the syntax but in a very different way, we may have to restructure a good portion of the pattern-matching epic.

Alternatives?

Finding a good alternative way to split it up is difficult. I'll try to take a more conservative route which attempts to preserve more of the spirit of the original proposal at the cost of not being able to subdivide it as much.

What if, instead of subdividing pattern-matching into tiny pieces, we instead focused on reducing its size by extracting features from the proposal and moving those into their own "add-on" proposals within the same epic. The core may still be larger, but it doesn't need to be as large as it is today.

And, instead of a layered approach, perhaps a graph approach? You have the core proposal, and a number of add-on proposals that depend on the core, and if needed, proposals can depend on specific add-on proposals (instead of the whole layer).

Here's what this could look like in practice:

The core proposal would still be larger. It needs to be strong enough that it's able to stand on it's own weight (i.e. people would enjoy using it, even if that's the only thing that got released), and have enough in there that we're able to hang everything else off of it, but it should be no larger than that.

Some features the core might include:

I think it's important that we consider all of these pieces in the same proposal if we ever hope to get a high-quality syntax that handles each concept well.

The core proposal would also need some sort of match control structure for it to have any value, so we can make it include the match (...) { ... } stuff from the current proposal. I tried to think of ways to push this off into a separate proposal, but there's really no way to do that without trying to introduce some sort of "lightweight" control structure syntax for the core proposal to use instead that's still powerful enough to show off all of the features from the core proposal (including bindings). There's, perhaps, ways to go that route, but part of my goal was to try and avoid inventing new features and syntaxes as we split this proposal up.

With a powerful core in place, we can move features like the following into add-on proposals:

I recognize that this still leaves the core at a much larger size compared to some of the other epic breakdowns presented, but if we deconstruct the proposal after this manner, it at least feels like the split-apart is less invasive. The other options presented thus far makes it feel like we'll be kicking ourselves back to ground zero, and trying to build ourselves back up using completely new syntax constructs.

(I know in the end that I'm just a community member, who doesn't have to actually worry about how these proposals go through the proposal process, but still, from where I'm standing, I feel like it would be easier to participate in discussions around the proposals if they were able to stand on their own weight, and I would also prefer if we didn't have to start from ground zero when we split it all up)

rbuckton commented 2 years ago

@rbuckton's extractor proposal is interesting once the future layers come on, but again, I feel the foundational proposal is lacking.

const isOk{ body } = response;

I assume isOk is implementing some sort of special protocol to make this work. This sounds like as much work as just doing this: [...]

I need to spend some additional time reading through your comments here, but I would like to point out that the foundational proposal for Extractors is more than a top-level const isOk{ body } = response. Extractors would allow you to inject custom logic into destructuring at any depth, which isn't feasible today (at least, not in a concise manner):

// nested extractors inside of an array destructuring
const [isOk{ body: body1 }, isOk{ body: body2 }] = await Promise.all([fetchRequest1(), fetchRequest2()]);

// picking apart an Option<Message>
match(result) {
  when (Option.Some(Message.Move{ x, y })): console.log(`move: ${x}, ${y}`);
  when (Option.Some(Message.Write(text))): console.log(`write: ${text}`);
  when (Option.None): console.log(`none`);
}
theScottyJam commented 2 years ago

Extractors would allow you to inject custom logic into destructuring at any depth

Right, I knew that, and forgot that :p. Ok, that does give the extractor base proposal more teeth.


May I ask a couple of clarifying questions with your epic break-apart?

I see that layer 1 brings your extractor proposal in (which, I dug up your rough draft on it to get a better picture, here's the link in case anyone else wants it).

Layer 2 then adds the match syntax, but at this point we don't have an actual concept of patterns. So, in the when (...) syntax, what sort of stuff is actually legal in the ...? Is it just extractors to start with? And the future layers have to add more functionality into the match construct?

To be honest, I've read through layer 3 a handful of times, and I'm struggling to understand what it's trying to additionally introduce (from either break-apart). I'm not sure what's meant by an "implicit value", which seems to be at the core of this layer.

And, layer 4 doesn't seem to add anything in particular - this layer seems to be more for @codehag's original epic separation, which tries to separate assignment from matching, and layer 4 added syntax back in to handle that, which yours didn't have to worry about since you weren't separating the two concepts to begin with.

At what layer do we deal with object and array matchers? (edit: And what about matching against dynamic values, e.g. ${...} or ^ syntax? And binding patterns? etc) Is that part of layer 2 with the match control structure? Is there any particular reason that, in this version, layer 1 needs to come before layer 2 and not the other way around? I don't see a particularly strong line of dependency between the match control structure and extractors, but I'm also very fuzzy on what goes where as well.

rbuckton commented 2 years ago

My "epic break-apart" isn't so much a break-apart as it is a diff with comments against @codehag's proposal. I can put together a more cohesive layering focusing specifically on the current pattern matching proposal + extractors, but roughly I'd break it down into the following parts:

  1. Destructuring (existing)
    • Destructuring is already very limited irrefutable matching (i.e., const {x} = null throws, const [x] = {} throws).
  2. Extractor objects are introduced as an extension to destructuring:

    • First true introduction to basic irrefutable pattern matching.
    • Extractor objects are an example of what will eventually be known as a custom matcher.
    • Extractor objects must have a Symbol.matcher method.
    • ObjectExtractorBindingPattern and ObjectExtractorAssignmentPattern syntax is introduced as an extension to object destructuring:

      ObjectExtractorBindingPattern :
      QualifiedName `{` BindingPropertyList `}`
      
      ObjectExtractorAssignmentPattern:
      QualifiedName `{` AssignmentPropertyList `}`
    • ArrayExtractorBindingPattern and _ArrayExtractorAssignmentPattern` syntax is introduced as an extension to array destructuring:

      ArrayExtractorBindingPattern :
      QualifiedName `{` BindingElementList `}`
      
      ArrayExtractorAssignmentPattern:
      QualifiedName `{` AssigmentElementList `}`
    • The QualifiedName syntax is limited to only dotted identifiers:
      QualifiedName :
      IdentifierReference
      QualifiedName `.` IdentifierName
    • When an Extractor object is evaluated, it's Symbol.matcher is called with the current subject (i.e., the Initializer or parent binding element), and the result is further destructured based on the extractor pattern in use.
    • Examples:
      const Option.Some(value) = obj;
      const Message.Move{ x, y } = msg;
      const isOk{ body } = response;
      const [result, InstantExtractor(start), InstantExtractor(end)] = traceStartEnd(() => someExpensiveOperation());
  3. Basic infix pattern matching with expr is Pattern:
    • First introduction to refutable pattern matching.
    • Does not introduce new lexical bindings.
    • Results in true or false.
    • if Pattern is a numeric (except unprefixed 0), bigint, or string literal, or is the identifier undefined, uses SameValue.
    • if Pattern is true, false, or null, uses SameValue.
    • if Pattern is Infinity, +Infinity, or -Infinity, uses SameValue.
    • If Pattern is +0 or -0, uses SameValueZero
    • if Pattern is NaN, equivalent to isNaN(expr).
    • if Pattern is an object-literal-like pattern, matches the properties of the pattern.
    • if Pattern is an array-literal-like pattern, matches the elements of the pattern using its iterator.
    • if Pattern is instanceof F, where F is a qualified name, equivalent to expr instanceof F.
    • if Pattern is typeof S, where S is a string literal, equivalent to typeof expr === S.
    • if Pattern is ( Pattern ), returns the result of evaluating is Pattern against the subject.
    • Pattern syntax/semantics can be expanded in other layers.
    • Examples:
      if (x is undefined) ...;
      if (x is typeof "string") ...;
  4. Pattern syntax expanded to include logical patterns using and, or, and not:
    • if Pattern is not Pattern, inverts the result of matching Pattern against the subject. For example:
      x is not undefined
      x is not 0
    • if Pattern is LeftPattern and RightPattern, returns true if both LeftPattern and RightPattern match.
    • if Pattern is LeftPattern or RightPattern, returns true if either LeftPattern or rightPattern match.
    • Precedence of and, or, and not are equivalent to the precedence of infix &&, ||, and unary-prefix !, respectively.
    • Examples:
      if (x is undefined or null) ...;
      if (x is typeof "string" or typeof "number") ...;
  5. Pattern syntax expanded to include relational patterns using <, <=, >, and >=:
    • Pattern is matched as if the subject is on the left side of the relational operator.
    • Examples:
      if (x is >= 0 and < 10) ...;
  6. Branching pattern matching via match expression, using layers 2-4.
    • Examples:
      match (compare(x, y)) {
      when (>0): ...;
      when (<0): ...;
      default: ...;
      }
  7. Inline let/const bindings in patterns:
    • When used in control flow or loop statements, introduces bindings scoped to the statement:
      if (obj is { x: 10, y: let y }) {
      /* y is in scope */
      } else {
      /* y is in scope, but not initialized */
      }
      // y is not in scope
    • When used in when clause of match, binding is scoped to when-leg of the match expression:
      match (obj) {
      when ({ x: 10, y: let y }): /* y is in scope */;
      default: /* y is not in scope */;
      }
  8. Custom matchers introduced, aligns with layers 1-6:

    • QualifiedName in pattern is a custom matcher.
    • ObjectExtractorMatchPattern and ArrayExtractorMatchPattern are also custom matchers.
    • Custom matchers are either a function, or an object with a Symbol.matcher method.
    • Custom matchers either return a boolean or a MatchResult.
    • If returns true, subject is the result.
    • If returns a MatchResult whose matched is true, the MatchResult's value is the result.
    • A valid match can be further destructured using extractor syntax.
    • Examples:
      
      if (x is Option.Some(let value)) /*use value*/;

    match (obj) { when (Message.Move{ x: > 10 and let x, y: > 20 and let y }): / use x and y /; when (Message.Write(let text)): / use text /; default: ...; }

    match (x) { when (String and { length: > 0 }): ...; }

  9. Interpolation in patterns (TODO)
  10. Regular Expressions as matchers (TODO)

There's a bit more to go, and these aren't strictly layers as some things should be merged together.

theScottyJam commented 2 years ago

Thanks for that detailed description @rbuckton, that does help me see your vision clearer.

I do like the idea of having expr is pattern as well, as that helps break things up into more steps, and I know there's been others in this proposal expressing a desire for something like that. It does, however, feel a bit odd to be able to introduce a declaration into any location where an expression is allowed, I'm sure that would open up a number of odd edge cases to deal with, which might make the overall complexity of figuring out the spec for is as difficult as the match construct, at which point it didn't really buy us anything to introduce is before match.

Some examples of added complexity:

if (<do declarations here become part of the `if` scope?>) { ... }
for (let i = <Would other variables introduced here persist across all iterations?>; <What happens here?>; <And here?>) { ... }
class MyClass {
  x = <what about here?>;
  y = <can I access something declared in the previous line from here?>;
}

I know a lot of the above is abusive of this new power, but it's all still complexity that would need to be discussed and ironed out in the same proposal that we're discussing object/array/primitive matchers. I guess another option is to just forbid declarations with is, and iron that out later or never.


I'm also not entirely sure why extractor objects needs to be the first step in the series of outlined steps. I don't believe future steps directly depend on it being there? (though feel free to clarify on this point). From what I can tell, we could very well start with step 2, the expr is pattern syntax, bring in patterns for primitives, arrays, and objects, etc. Once that's in place, I believe we could do pretty much all of the other steps you showed in parallel (including extractors). Some of the steps are certainly interdependent, and we could choose one step to place on a layer lower than another in order to prevent them from tripping over each other, but if there's relationship isn't overly strong, perhaps it's ok to let them simply sit on the same layer, and make sure we're aware of how they relate as we work on them. (though, here I'm probably not being my brightest, and am forgetting some important correlations between some of the steps, that would make them difficult to do in parallel).

codehag commented 2 years ago

A couple of responses from my end:

The layering doesnt represent splitting into sub proposals (though admittedly i thought of that initially). Each layer allows us to consider a specific aspect of the proposal in isolation, and that is a significant benefit. This is similar to what the modules compartments proposal is doing, and it looks like other delegates have also noted the problem related to larger, complex, full featured proposals.

  1. The foundational proposal in both of the proposed split-ups seem to be fairly week.

I don't think so. The foundational proposal in both introduces ubiquitous patterns, similar to Daniel Ehrenberg's Guard proposal. How this is done (if it is done first, or if we leave the capability open for later) is up for discussion. This is really incredibly powerful. What i presented is just a sketch, not intended as a full fledged proposal. It is one way this can be done.

  1. We're introducing new features into the pattern-matching epic that can't be changed or removed, unless we restructure the whole epic.

I am also a bit wary of deviating too far from the pattern matching proposal as is. A lot of excellent research has gone into it. However, this may give additional argument for delaying decisions on syntax short hand. My priority would be the introduction of patterns first, followed by the ability to author custom matchers.

My goal was to introduce a way to think about this proposal in parts, because in my view -- if we don't, we will force ourselves into situation where we have many inconsistent ways of doing things in the language. My break down is partially in response to the original problem statement:

"There are many ways to match values in the language, but there are no ways to match patterns beyond regular expressions for strings."

This is right on point and an excellent observation. But, if we move forward with the current syntax, which blends de-structuring and pattern matching, we will not be able to achieve it in a way that is not only local to a match statement. Resulting in potential inconsistencies later on.

theScottyJam commented 2 years ago

The layering doesnt represent splitting into sub proposals

Oooh. So, are we still talking about creating layers but having a single proposal repo? And then, in committee meetings, you just work on passing off one layer at a time, instead of trying to discuss the whole giant proposal at once with everyone?

This is right on point and an excellent observation. But, if we move forward with the current syntax, which blends de-structuring and pattern matching, we will not be able to achieve it in a way that is not only local to a match statement. Resulting in potential inconsistencies later on.

This is certainly a valid concern. I don't think this technically means we need to change things now as we're splitting it up to ensure this happens. If we think about it from the "core proposal with a graph of add-ons" idea, another valid path would be to let the core proposal continue to have the current "match" construct and then create a new "add-on" proposal with the "const { body } when isOk(response);" syntax. This add-on would show flaws that would arise if we try to introduce syntax like this as-is, and will force us to figure out what we'd need to retroactively alter in the core proposal to make this work, and decide if we really want to go this route. Using a more layered model, I'm sure we could push const { body } when isOk(response); syntax to a higher layer, so it still gets discussed, but we don't have everything building off of it. Or, these don't have to be separate proposals/layers, they could just be discussion threads requesting to change the core, and there the communittee and delegates can hash things out to see how viable and useful the idea is.

I know this makes it harder to split things up finer-grain. I guess my common worry is introducing new features into the proposal primarily to make it easier to split it up. It just seems like a split-up discussion would be much simpler if we only looked at how to split up what we currently have (though, I agree that it would be much harder to do a fine-grain split-up like you did without adding anything new or changing any behaviors). If, a proposed split-up requires features X and Y to make happen, then we additionally have to discuss here if we want features X and Y and we have to commit to them, because, once we build the layers around them, there's no turning back (it's not like you can easily rearrange the layers after we've gotten committee approval for each individual layer). At the same time, I think it would be difficult to give these features a proper discussion if we're trying to do it at the same time as we're talking about how we should split up pattern-matching.

rbuckton commented 2 years ago

I'm also not entirely sure why extractor objects needs to be the first step in the series of outlined steps.

It doesn't necessarily need to be the first step, its more of a side path alongside the rest of pattern matching up until (7) above. However, that's not easy to represent in a markdown list.

rbuckton commented 2 years ago

I do like the idea of having expr is pattern as well, as that helps break things up into more steps, and I know there's been others in this proposal expressing a desire for something like that. It does, however, feel a bit odd to be able to introduce a declaration into any location where an expression is allowed, I'm sure that would open up a number of odd edge cases to deal with, which might make the overall complexity of figuring out the spec for is as difficult as the match construct, at which point it didn't really buy us anything to introduce is before match.

There is prior art we can lean on here. For example, C# allows you to introduce inline variables in a few specific places such as inline out variables (added in C# 7.0), and pattern matching (1, 2, 3), and has well-defined semantics on scoping for those variables.


Some examples of added complexity:

if (<do declarations here become part of the `if` scope?>) { ... }

In C#, inline variables in the head of an if are scoped to the if statement:

if (x is string y && y.Length > 10) { ... }

Here, y can be used after its declaration in the head of the if statement, and in the "then" part of the if statement, but C#'s definite assignment analysis errors on usage along code paths where y is not initialized (such as in the else clause, or in an expression like x is string y || y.Length, where the y wouldn't have been assigned by the is). This is essentially the same as TDZ in JS. y also would be unreachable outside of the if statement.


for (let i = <Would other variables introduced here persist across all iterations?>; <What happens here?>; <And here?>) { ... }

In C#, inline variable declarations in a for statement are scoped to the portion of the for statement in which they were defined:

for (var a = x is string y; /*1*/; /*2*/) /*3*/;

Here, y is unreachable in (1), (2), and (3). The same is true in these cases as well:

for (var a = /*1*/; x is string y; /*2*/) /*3*/;
for (var a = /*1*/; /*2*/; x is string y) /*3*/;

In a while statement, inline variables are scoped to the rest of the while statement head and the loop body, much like if:

while (x is string y && y.Length > 0) {
  Console.WriteLine(y);
}

In a do statement, inline variables are scoped only to the expression of the while clause:

do {
 // y cannot be used here
}
while (x is string y && y.Length > 10); // y can be used here

class MyClass {
  x = <what about here?>;
  y = <can I access something declared in the previous line from here?>;
}

In this case, local variable declarations would be scoped only to the expression. This would align with the specification which today reads that the initializer of a field is evaluated as if it were a function, including with a valid this binding.


I know a lot of the above is abusive of this new power, but it's all still complexity that would need to be discussed and ironed out in the same proposal that we're discussing object/array/primitive matchers. I guess another option is to just forbid declarations with is, and iron that out later or never.

This isn't an abuse, it is an intended outcome. The scoping of inline variables in if and while above is no different than Rust's if..let and while..let statements:

// excerpt from https://doc.rust-lang.org/rust-by-example/flow_control/if_let.html
let number = Some(7);
let letter: Option<i32> = None;

if let Some(i) = number {
  println!("Matched {:?}!", i);
}

if let Some(i) = letter {
  println!("Matched {:?}!", i);
} else {
  // Destructure failed. Change to the failure case.
  println!("Didn't match a number. Let's go with a letter!");
}

// excerpt from https://doc.rust-lang.org/rust-by-example/flow_control/while_let.html
let mut optional = Some(0);

while let Some(i) = optional {
  if i > 9 {
    println!("Greater than 9, quit!");
    optional = None;
  } else {
    println!("`i` is `{:?}`. Try again.", i);
    optional = Some(i + 1);
  }
}

Here is the same example from Rust, but written with Pattern Matching + Extractors:

// based on excerpt from https://doc.rust-lang.org/rust-by-example/flow_control/if_let.html
const number = Option.Some(7);
const letter = Option.None;

// Rust-style `if let` using destructuring
if (let Option.Some(i) = number) {
  console.log(`Matched ${i}!`);
}

// C#-style `if` using `is`
if (number is Option.Some(let i)) {
  console.log(`Matched ${i}!`);
}

// Rust-style `if let` using destructuring
if (let Option.Some(i) = letter) {
  console.log(`Matched ${i}!`);
} else {
  console.log("Didn't match a number. Let's go with a letter!");
}

// C#-style `if` using `is`
if (letter is Option.Some(let i)) {
  console.log(`Matched ${i}!`);
} else {
  console.log("Didn't match a number. Let's go with a letter!");
}

// based on excerpt from https://doc.rust-lang.org/rust-by-example/flow_control/while_let.html
let optional = Optional.Some(0);

// Rust-style `while let` using destructuring
while (let Option.Some(i) = optional) {
  if (i > 9) {
    console.log("Greater than 9, quit!");
    optional = Option.None;
  } else {
    console.log(`'i' is '${i}'. Try again.`);
    optional = Option.Some(i + 1);
  }
}

// C#-style `while` using `is`
while (optional is Option.Some(let i)) {
  if (i > 9) {
    console.log("Greater than 9, quit!");
    optional = Option.None;
  } else {
    console.log(`'i' is '${i}'. Try again.`);
    optional = Option.Some(i + 1);
  }
}
rbuckton commented 2 years ago

Here's more information on the scope of pattern variables in C# 7.0 (though later editions have expanded upon this): https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-7.0/pattern-matching#scope-of-pattern-variables

theScottyJam commented 2 years ago

Oh, that's a nice correlation with C#'s out params. I didn't think about that.

I will also say, if we do ever with to introduce an is operator like this, that's allowed to make bindings at any location, I'd much prefer using syntax that requires us to put a let/const/var somewhere in the match syntax on the RHS of is (unlike the current proposal).

// You almost don't notice that `b` is being declared
if (x is ['a', b]) { ... }

// It's much easier to see that declarations are happening here
if (x is ['a', const b]) { ... }
ljharb commented 2 years ago

I think it’s very premature, fwiw, to imagine how the syntax might change as a result of this feedback, given that the champion group hasn’t discussed it yet :-)

tabatkins commented 1 year ago

Given that we champions are in reasonable agreement that patterns should be usable in several places, I spent some time figuring out what I think the most consistent syntax for integrating them into everything we need is. I have a fuller gist at https://gist.github.com/tabatkins/ee6dfe274e81d76b6069c5ed37d3dcbf#using-matchers, but I'll reproduce them here.

I'd appreciate y'all's thoughts!

rbuckton commented 1 year ago
  • New if form:

    if match(when(<matcher>) = <val-expr>) {
      // passes if `val` matches the `matcher`,
      // and also exposes the matcher's bindings
      // to the body of the `if`.
    } else if match(when(<matcher>) = <val-expr>) {
      // same but with a new val
    }

    (Can use when(...) if(...) or even plain if(...) here, and in later constructs further down.)

I don't understand the need for all of the extra syntax here. If we are considering an is infix expression, then if match and while match seem unnecessary and overcomplicated compared to:

if (<val-expr> is <pattern>) {
}

while (<val-expr> is <pattern>) {
}

do {
}
while (<val-expr> is <pattern>);

I also don't see much value in a for match statement when you can already do for (...) if (...) {}, which would also work just as well with is:

for (let x of <val-expr>) if (x is <pattern>) {
}

// which is essentially just

for (let x of <val-expr>) {
  if (x is <pattern>) {
  }
}
  • New let/const form:

    let when(<matcher>) = val;
    // Establishes all the bindings from the matcher
    // as part of the surrounding scope,
    // using `let` or `const` bindings as appropriate.

    Doing it manually with match() would be:

    match(val) {
      when(<matcher>): do {
          // all following code in the block context nests in here
      };
    }

    Note that this throws if the match fails, since there's no default clause. (This doesn't use the match keyword like the other forms, as it's not needed and would interfere with the overall structure of the let statement. if() clauses will still work here, like the other forms.)

Regarding the let/const form, I can sympathize with a goal to somehow align more to assignment patterns/destructuring, but this syntax not only feels complex, it collides with Extractors since when isn't a keyword:

let Foo(x) = val;
  • New is operator

    let passes = <val-expr> is when(<matcher>);

    Evaluates to true/false if val passes the matcher or not.

This is another case of a far more complex syntax than is warranted. I think <val-expr> is <pattern> is more than clear enough without the need to add an extraneous when.

I also have a gist of various explorations in to pattern matching syntax that can be found here: https://gist.github.com/rbuckton/df6ade207eecad4fc94cedc3aae79ceb. Not everything in that document is something I would propose for adoption, however. If I were to distill it down to the barest and most useful MVP syntax, I would propose the following:

I find this approach to require the least boilerplate and cuts reduces the number of moving parts you need to remember. It also has very clear and consistent semantics.

I'll put together a separate gist with more details for this proposal, but it boils down to this:

// simple matching with `is`:
if (val is 0 | 1 | 2) { ... }
if (val is String | Number) { ... }
if (val is Point(0, 0)) { ... }
if (val is { length: 10 }) { ... }

// branch matching with `match`
const y = match (val) {
  // `when` clauses
  when 0 | 1 | 2: expr;
  when String | Number: expr;
  when Point(0, 0): expr;
  when { length: 10 }: expr;

  // `when..if` clauses
  when Array if (val.length > 10): expr;

  // `if` clauses
  if (myGuardFunction(val)): expr;

  // `default` clauses
  default: expr;
};

// NOTE: 'equiv:' behavior is approximate. `or:` behavior is even more approximate and is only meant to provide
//       context to a more complex desugaring in `equiv:`

// pattern syntax: literal constants
val is 0;                                       // equiv: val === 0
val is 1n;                                      // equiv: val === 1n
val is true;                                    // equiv: val === true
val is "hello";                                 // equiv: val === "hello"
val is null;                                    // equiv: val === null

// pattern syntax: type tests (via custom matchers/`instanceof`)
val is String;                                  // equiv: (%InvokeMatcher%(val, String) !== ~not-matched~)
                                                //    or: typeof val === "string"

val is Array;                                   // equiv: (%InvokeMatcher%(val, Array) !== ~not-matched~)
                                                //    or: Array.isArray(val)

val is Foo.Bar;                                 // equiv: (%InvokeMatcher%(val, Foo.Bar) !== ~not-matched~)

// pattern syntax: exhaustive object patterns
val is { kind: "move", direction: "up" };       // means: `val` must have properties named "kind" and "direction". If it
                                                //        does, `val.kind` must be `"move"` and `val.direction` must be
                                                //        `"up"`. If they are, `val` must not have any other own
                                                //        enumerable properties.
                                                //
                                                // equiv: (Reflect.has(val, "kind") && val.kind === "move") &&
                                                //        (Reflect.has(val, "direction") && val.direction === "up") &&
                                                //        (k = new Set(Reflect.ownKeys({ ...val })),
                                                //         k.delete("kind"),
                                                //         k.delete("direction"),
                                                //         k.size === 0)

// pattern syntax: non-exhaustive object patterns
val is { kind: "move", direction: "up", ... };  // equiv: val.kind === "move" && val.direction === "up"

// pattern syntax: exhaustive "array" patterns
val is [0, 1];                                  // means: `val` must have a `[Symbol.iterator]()` method. If it does,
                                                // get the iterator `it` of `val`. The first resulted yielded by `it`
                                                // must be `0`. If it is, the second result yielded by `it` must be `1`.
                                                // If `it` does not yield a third result, indicate success. However,
                                                // if `it` does yield a third result, close the iterator and indicate
                                                // failure.
                                                //
                                                // equiv: (val !== null && val !== undefined) &&
                                                //        (f = val[Symbol.iterator], typeof f === "function") &&
                                                //        (it = f.call(val), true) &&
                                                //        (r = it.next(), !r.done && r.value === 0) &&
                                                //        (r = it.next(), !r.done && r.value === 1) &&
                                                //        (r = it.next(), r.done || (it?.return(), false));
                                                //
                                                //    or: val[0] === 0 && val[1] === 1 && val.length === 2

// pattern syntax: non-exhaustive "array" patterns
val is [0, 1, ...];                             // means: `val` must have a `[Symbol.iterator]()` method. If it does,
                                                // get the iterator `it` of `val`. The first resulted yielded by `it`
                                                // must be `0`. If it is, the second result yielded by `it` must be `1`.
                                                // If it is, close the iterator and indicate success.
                                                //
                                                // equiv: (f = val?.[Symbol.iterator], typeof f === "function") &&
                                                //        (it = f.call(val), true) &&
                                                //        (r = it.next(), !r.done) && 
                                                //        (r.value === 0) &&
                                                //        (r = it.next(), !r.done) && 
                                                //        (r.value === 1) &&
                                                //        (it.return(), true);
                                                //
                                                //    or: val[0] === 0 && val[1] === 1

// pattern syntax: negation
val is not 0;                                   // equiv: val !== 0
val is not null;                                // equiv: val !== null

val is not String;                              // means: Evaluating the custom matcher `String` against `val` must not
                                                //        succeed.
                                                //    or: typeof val !== "string"

val is not Array;                               // means: Evaluating the custom matcher `Array` against `val` must not
                                                //        succeed.
                                                //    or: !Array.isArray(val)

val is not { length: 10, ... };                 // means: `val` must not have a `length` property whose value is `10`.
                                                //    or: val?.length !== 10

val is { length: not 10, ... };                 // means: `val` must have a `length` property. If it does, the value of
                                                //        `val.length` must not be `10`.

// pattern syntax: disjunction
//    NOTE: This syntax uses `or` to preserve `|` for bitwise OR since bitwise operators may be useful for numeric
//          flags-based enums in the future.
val is 0 or 1;                                  // equiv: (val === 0) || (val === 1)

val is String or Number;                        // means: Evaluate custom matcher `String` against `val`.
                                                //        If that does not succeed, match custom matcher `Number`
                                                //        against `val`.
                                                //    or: (typeof val === "string" || typeof val === "number")

// pattern syntax: conjunction
//    NOTE: This syntax uses `and` to preserve `&` for bitwise AND since bitwise operators may be useful for numeric
//          flags-based enums in the future.
val is Array and { length: 10, ... };           // means: Evaluate custom matcher `Array` against `val`.
                                                //        if that succeeds, `val` must at least have a `length` property
                                                //        that matches `10`.

// pattern syntax: extractors
val is Option.Some(10);                         // means: Evaluate custom matcher `Option.Some` against `val`.
                                                //        If that succeeds, destructure the iterable result into a
                                                //        single element that matches `10`

val is Point({ x: 10, y: 20 });                 // means: Evaluate custom matcher `Point` against `val`.
                                                //        If that succeeds, destructure the iterable result into a
                                                //        single element that matches `{ x: 10, y: 20 }`.

// pattern syntax: `let`/`const` (and maybe `var`?) patterns
//    NOTE: `let`/`const` patterns are "irrefutable matches" which always succeed.
//          `let`/`const` patterns introduce named bindings that can be used elsewhere in the pattern, the body of a
//          `match-when` clause, the body of the containing `if` statement (when used with `is` in an `if` head),
//          or the body of a `while` loop (when used with `is` in a `while`-loop head).

if (val is Array and { length: let len, ... }) {  // `len` scoped to pattern and `if` body
  len;
}

const r = match (val) {
  when { x: let x, y: let y, ... }: x + y;        // `x` and `y` scoped to pattern and clause body

  when [let x, let y, ...] if (x > 10): x - y;    // `x` and `y` scoped to pattern, `if`, and clause body
}

This leaves room for RegExp patterns, though those could also be pulled out and implemented via extractors:

const SimpleDateRegExp = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;

if (x is SimpleDateRegExp({ groups: let { year, month, date }})) {...}

This also leaves room for future growth in pattern syntax to accomodate relational patterns, such as:

x is >= 10 and < 100
rbuckton commented 1 year ago

It's also possible to define match in terms of is, conditional expressions, and throw expressions:

const res = match (val) {
  when 0: a();
  when 1: b();
  when 2: c();
};

// equiv. using `is`
const res = 
  val is 0 ? a() :
  val is 1 ? b() :
  val is 2 ? c() :
  (throw new MatchError("Match not found"));

Though, match has the benefit of not requiring a temporary variable to cache the expression being matched (so that it isn't reevaluated) and implicitly handles match failure.

tabatkins commented 1 year ago

I don't understand the need for all of the extra syntax here.

All my when()s are me being conservative with grammar. If the matchers aren't wrapped, then we'll have to be a lot more careful with syntax in the future. That might be fine! But I wanted to start out safer and relax as needed, rather than designing aggressively and having to pare it back.

It also makes it possible to build in the if() stuff consistently, while there's no consistent way to do it without the wrapper. (I don't want to create incentives for people to make a "custom matcher" that just takes a boolean and returns it, so they can get arbitrary tests into their patterns.)

Just plain being consistent is also pretty important. If we can chart a simpler, consistent syntax, that's great, but if we pay for simplicity with each syntax being more special-casey it might not be worth it. (It does look like there might well be a nice consistent route with is, tho, which would be great to explore.)

Also, this aligns the general shape of the block syntaxes with Rust, which isn't at all a requirement, but is nice.

I also don't see much value in a for match statement

One less level of nesting, and consistency with the other blocks.

Regarding the let/const form, I can sympathize with a goal to somehow align more to assignment patterns/destructuring, but this syntax not only feels complex,

Can you explain? Is this just the presence of the when() wrapper again? Aside from that it's literally as simple as it can possibly be, as it's just the current syntax with destructuring swapped out for a matcher.

it collides with Extractors since when isn't a keyword:

That clashes just because Extractors wants to also be usable as a destructuring pattern, yeah? If it's instead just a matcher pattern this wouldn't be an issue.

I think is is more than clear enough without the need to add an extraneous when.

It's absolutely clear to a reader at a high level, yeah. I'm not certain it's clear from a grammar perspective, which is why I'm guarding it right now. Happy to be proven wrong.

I also have a gist of various explorations in to pattern matching syntax

Yup, this nearly exactly matches my current gist, assuming we move ident matchers to a dedicated pattern and allow extractors, as I'm suggesting at the end.

I especially like the addition of not in the pattern. I presume this means that the subtree under a not does not contribute bindings, tho it can accumulate them during evaluation.

The lack of interpolation syntax is an issue, tho - it means you cannot write a dynamic pattern, and are instead forced to construct the matcher outside of the pattern and then reference it by name. (As you show with the regex example at the end.) That's very unfortunate, but also the lack of interpolation isn't necessary here. You've just eliminated the need for interpolation for common patterns (referencing an existing variable), which is pretty good, but that doesn't prevent us from still having interpolation as needed.

relational patterns

I'm still completely unconvinced we need or want relational patterns; it seems like it would be a big anchor around the neck of parsing forever, with the benefit solely being that you don't need to bind a name in order to compare it. And it requires the RHS to be arbitrary value expression, not a pattern, which is the sort of unexpected mid-pattern context switch we've tried to avoid without explicit signaling/bounds (like the interpolation pattern).

tabatkins commented 1 year ago

It's also possible to define match in terms of is...

That's not possible, unless you're saying that the is expression will generate bindings that are visible only to the positive leg of the ternary.

rbuckton commented 1 year ago

That is precisely what I would be suggesting, just like how if (x is Option.Some(let value)) { value; } would only generate bindings in the "then" branch and not the else branch.

rbuckton commented 1 year ago

It's absolutely clear to a reader at a high level, yeah. I'm not certain it's clear from a grammar perspective, which is why I'm guarding it right now. Happy to be proven wrong.

The committee has already balked at the enormous syntax budget of pattern matching. I am concerned this is more likely to further that concern than to address it.

The lack of interpolation syntax is an issue, tho - it means you cannot write a dynamic pattern, and are instead forced to construct the matcher outside of the pattern and then reference it by name. (As you show with the regex example at the end.) That's very unfortunate, but also the lack of interpolation isn't necessary here. You've just eliminated the need for interpolation for common patterns (referencing an existing variable), which is pretty good, but that doesn't prevent us from still having interpolation as needed.

I think having a dynamic pattern in the middle of a pattern is more likely to make the pattern far too hard to read, and would strongly suggest that we don't encourage that approach. Are there any languages that support that today? All major languages with similar pattern matching features that I can think of don't have that notion, though a few are somewhat ambiguous as to what is a reference vs. what is a binding (which let patterns are intended to solve).

rbuckton commented 1 year ago

I'm still completely unconvinced we need or want relational patterns;

They're quite useful in C#, i.e.:

string WaterState(int tempInFahrenheit) => tempInFahrenheit switch
{
    (> 32) and (< 212) => "liquid",
    < 32 => "solid",
    > 212 => "gas",
    32 => "solid/liquid transition",
    212 => "liquid / gas transition",
};

it seems like it would be a big anchor around the neck of parsing forever, with the benefit solely being that you don't need to bind a name in order to compare it.

While that is convenient, yes, it also avoids repetition and avoids jumping out of pattern space and into expression space. I expect you are also more likely to do a relational comparison against a number than you are to do a === comparison.

And it requires the RHS to be arbitrary value expression, not a pattern, which is the sort of unexpected mid-pattern context switch we've tried to avoid without explicit signaling/bounds (like the interpolation pattern).

We don't have to allow arbitrary expressions. In C#, relational operators are restricted to constant expressions:

The right-hand part of a relational pattern must be a constant expression. The constant expression can be of an integer, floating-point, char, or enum type.

I find relational patterns far more compelling than RegExp literal patterns, since RegExp literal patterns are easily represented by an Extractor pointing to a variable outside of the pattern. However, I'm not discounting either as potential pattern syntax. I just don't believe either are compelling enough to achieve an MVP solution for pattern matching.

tabatkins commented 1 year ago

We don't have to allow arbitrary expressions. In C#, relational operators are restricted to constant expressions:

I feel like not being able to compare against the value of a variable would be a very odd and limiting restriction, imo. Especially since you can do so with equality.

rbuckton commented 1 year ago

I feel like not being able to compare against the value of a variable would be a very odd and limiting restriction, imo. Especially since you can do so with equality.

I'm not saying we have to 100% follow C# semantics. I think restricting the comparison to literal constant values and IdentifierReference would be perfectly acceptable. What I wouldn't want to support is a pattern like when > (foo ? 1 : 0). You restrict it to a limited subset of UnaryExpression, such as only allowing prefix +/-/~. Anything else you would have to pull out of the pattern or use an if.

That said, I think the rabbit hole of a relational pattern design is going too off-topic in this issue, and is something we can discuss offline or in a separate issue.

rbuckton commented 1 year ago

Regarding the let/const form, I can sympathize with a goal to somehow align more to assignment patterns/destructuring, but this syntax not only feels complex,

Can you explain? Is this just the presence of the when() wrapper again? Aside from that it's literally as simple as it can possibly be, as it's just the current syntax with destructuring swapped out for a matcher.

I'm not convinced it's necessary to support deeply nested pattern matching to normal variable declarations. Binding and assignment patterns are more about reaching in to get a value than they are about testing conditions and alternatives, so I don't think match patterns are a good fit.

The primary purpose of an extractor is to extract values from something. Since it serves both value extraction cases and input validation cases, it is well suited to cover both destructuring and matching. Something like let when(10 or String) x = y only serves to obfuscate what should be an assertion, especially since it looks like a type annotation yet only validates the initialization site, so most of the capabilities of matching would be wasted on a let/const.

tabatkins commented 1 year ago

I don't understand the distinction you're drawing. the extractor Foo(a, b) is identical in spirt to the interpolation pattern ${Foo} with [a, b]; anything you'd want to do with one you'd do with the other, and in particular any nesting you'd do with one you'd do with the other. There's nothing semantically separating the two.

let when(10 or String) x = y isn't the form I was suggesting, fwiw - it's let when(10 or String) = y, aka identical to the form suggested for if() and while(), allowing you to validate and destructure a value via a pattern for the rest of your block scope (rather than requiring you to nest the rest of your function into the block of an if or similar).

That means that let when(10 or String) = y would indeed be a useless thing to say, as it doesn't establish any bindings at all; possibly we'd want it to be a syntax error to use this form without having a binding? But let [] = "foo"; is useless-but-valid today, so probably there's no need to guard authors against this. Having a "throw if it doesn't match the pattern" be very easy to express doesn't seem bad, after all.

rbuckton commented 1 year ago

let when(10 or String) x = y isn't the form I was suggesting, fwiw - it's let when(10 or String) = y, aka identical to the form suggested for if() and while(), allowing you to validate and destructure a value via a pattern for the rest of your block scope (rather than requiring you to nest the rest of your function into the block of an if or similar).

let when(10 or String) = y doesn't make any sense to me as a reader, and I'm definitely not in favor of requiring further nesting for extractors, i.e. let when(Option.Some(let x)) = y, as it looks like you're extracting a when function call into a Option.Some, like you might for Option.Some(Mesage.Move({ x: let x, y: let y })) = y. The when syntax is also incompatible with destructuring assignments since when is not reserved.

If you just want to validate that y matches a pattern, just do if (y is not 10 or String) throw new MatchError(), or assert(y is 10 or String). There's no need to bring a let into this.

tabatkins commented 1 year ago

as it looks like you're extracting a when function call into a Option.Some

It only looks like that if Extractors are part of destructuring as well as pattern-matching. In which case we'd have a parsing conflict anyway. I assume we'd do one or the other.

The when syntax is also incompatible with destructuring assignments since when is not reserved

No, it's fine today; let when() = foo isn't a valid destructuring pattern. The problem only comes if we wanted to do Extractors, with the current proposed syntax, as destructuring patterns as well.

If you just want to validate that y matches a pattern,

I'm not saying that's the intended use. The purpose of the let when(...) = val pattern is to pattern-match and create bindings, and also validate while you're at it, identical to what you'd do in a match() arm, but without requiring you to nest the rest of your function into a match to see those bindings.

If you do write a matcher that doesn't establish any bindings, you still get some use out of it, but it's indeed a little weird. But no reason to disallow it.

rbuckton commented 1 year ago

as it looks like you're extracting a when function call into a Option.Some

It only looks like that if Extractors are part of destructuring as well as pattern-matching. In which case we'd have a parsing conflict anyway. I assume we'd do one or the other.

I definitely want to use them for both. I want the simple case of const Option.Some(value) = x for use with ADT enums, but I also want them for pattern matching, just like we will have pattern matching equivalents for ObjectAssignmentPattern/ObjectBindingPattern ({}) and ArrayAssignmentPattern/ArrayBindingPattern ([]). I think we must have both.

The when syntax is also incompatible with destructuring assignments since when is not reserved

No, it's fine today; let when() = foo isn't a valid destructuring pattern. The problem only comes if we wanted to do Extractors, with the current proposed syntax, as destructuring patterns as well.

This is less about parsing ambiguity and more about the cognitive overhead of having to deal with two different interpretations of foo() directly nested within each other. I don't find let when() intuitive at all.

If you just want to validate that y matches a pattern,

I'm not saying that's the intended use. The purpose of the let when(...) = val pattern is to pattern-match and create bindings, and also validate while you're at it, identical to what you'd do in a match() arm, but without requiring you to nest the rest of your function into a match to see those bindings.

If you do write a matcher that doesn't establish any bindings, you still get some use out of it, but it's indeed a little weird. But no reason to disallow it.

I am just not sold on let when syntax. I don't think it solves the reference vs. binding issue without having to introduce other syntax to disambiguate (i.e. ${}). It doesn't read well left-to-right like the rest of JS does, and it feels like it's just an attempt to shoehorn in when everywhere for some kind of thematic consistency that I don't think is necessary. I've based a lot of the syntax I'm proposing off of Rust and C#, but I don't think I've come across any prior art that introduces the level of complexity of let when et al. Patterns themselves will exhaust our syntax budget and push the limits of what we can get consensus on. I don't think we need to overcomplicate the normal if, for, let, etc. when we can just use match and is as our entrypoints into matching.

I think what would be helpful if we're going to compare syntax would be to put together some examples of how real-world code might be adapted to pattern matching in ways that exercise the various syntax options we're proposing here so that we can get an idea of what each might look like in practice.

tabatkins commented 1 year ago

This is less about parsing ambiguity and more about the cognitive overhead of having to deal with two different interpretations of foo() directly nested within each other. I don't find let when() intuitive at all.

Sure, but my statement there was a response to your comment that it was incompatible. Not liking let when() is different from let when() being incompatible with destructuring - it's only incompatible with "destructuring + extractors".

My suggested syntaxes maintained as consistent of a syntax as possible between all the uses of matchers, but there are definitely other options.

I am just not sold on let when syntax. I don't think it solves the reference vs. binding issue without having to introduce other syntax to disambiguate (i.e. ${}). It doesn't read well left-to-right like the rest of JS does, and it feels like it's just an attempt to shoehorn in when everywhere for some kind of thematic consistency that I don't think is necessary.

I... just don't understand what you're saying here. A matcher is, generally, just "better destructuring" + an ability to fail the destructure. There's no semantic difference between [a, b] as a destructuring pattern and as a matcher (just a mechanical difference of the length check). Both express the exact same semantic - interpreting some object as a iterator, grab the first two items and bind them to a and b.

So I have no idea what you mean by this objection. What is the distinction you're drawing, such that let <destructuring-pattern> = x is fine, but let <matcher-pattern> = x is not? Why do your arguments there not apply?

I don't think we need to overcomplicate the normal if, for, let, etc. when we can just use match and is as our entrypoints into matching.

Now this I understand. Your suggestion about how to interpret an if(... is ...) as creating bindings sounds pretty reasonable! And the same for while(). I don't understand how it would work for for() or let, tho. And as much as reasonably possible, I'd like these to all look consistent. (We can change match() to be consistent with the other blocks, if that ends up being the issue.)

tabatkins commented 1 year ago

So, in your earlier comment you proposed using is more broadly, but only applied that to if() specifically. That is, we got:

let x = match(val) {
    when <matcher1>: <expr> /* matcher1's bindings visible here */;
    when <matcher2>: <expr> /* matcher2's bindings visible here */;
}

if(val is <matcher1>) {
    // matcher1's bindings visible here
} else if(val2 is <matcher2>) {
    // matcher2's bindings visible here
}

We'd need to figure out what happens if you write if(val is <matcher1> && val2 is <matcher2>), or toss on additional non-is boolean expressions, but I think this is workable, and easy to read.

But this omitted the other constructs, which I think are useful. while() seems easy:

while(val is <matcher>) {
    // matcher's bindings visible here
}

Note that Rust supports if let and while let, meaning there's existence proof that both of these forms are useful.

For for(), you suggested adapting it to:

for(let x of items) if(x is <matcher>) {
  // matcher's bindings visible here (plus the iteration bindings, as normal)
}

This seems reasonable to me. In one way it's less consistent than while(), but really while() is a spicy if(), so having those two be the most consistent is probably right, and having for() be a little different is probably okay. Maybe we can even make that:

for(let x of items if <test>) {
    ...
}

which really follows the Python syntax for iteration literals, which is neat!

But I don't see how to adapt this pattern to let, which seems like it would be an annoying lack. First, let can destructure, and matching is basically spicy destructuring; I don't see an argument for one that doesn't also argue for the other. Secondly, those bindings you get out of the matcher are genuinely useful; requiring people who want them to instead wrap the rest of their function in a if(is) construct seems annoying.

(Rust doesn't have the equivalent, but I suspect that's because of the thrown-error situation being less loosey-goosey in Rust. There's nothing special about the bindings situation between let and a block in Rust otherwise, afaict - they both have well-defined scopes.

rbuckton commented 1 year ago

But this omitted the other constructs, which I think are useful. while() seems easy:

while(val is <matcher>) {
  // matcher's bindings visible here
}

Note that Rust supports if let and while let, meaning there's existence proof that both of these forms are useful.

I wasn't discounting while et al, I even referenced it specifically in my comment above. Since is is just a binary expression, it could be used in if, while, for, conditionals, etc. Its versatility is the reason I proposed it.

Maybe we can even make that:

for(let x of items if <test>) {
  ...
}

which really follows the Python syntax for iteration literals, which is neat!

I don't think moving the if into the for is necessary. You mention Python, but that syntax is unique to comprehensions and conditionals. There isn't an inline if in a normal for statement in Python.

But I don't see how to adapt this pattern to let, which seems like it would be an annoying lack. First, let can destructure, and matching is basically spicy destructuring; I don't see an argument for one that doesn't also argue for the other. Secondly, those bindings you get out of the matcher are genuinely useful; requiring people who want them to instead wrap the rest of their function in a if(is) construct seems annoying.

If you want to introduce bindings, you could just do:

let { x, y } = val is Point(let x1, let y1) ? { x: x1, y: y1 } : null; // will throw if fails.

or just use is:

// x and y are in scope and in TDZ and are only initialized if the pattern matches.
val is Point(let x, let y);
console.log(x);
rbuckton commented 1 year ago

Or even better:

assert(val is Point(let x, let y));
tabatkins commented 1 year ago

I don't think moving the if into the for is necessary. You mention Python, but that syntax is unique to comprehensions and conditionals. There isn't an inline if in a normal for statement in Python.

Yes, I know, but we're not Python. My point is that if if() and while() can usefully use matchers, it would be weird for for() (the more common of the looping structures) to not be able to.

That said, I'm no certain what your objection is here. Are you arguing for for(...) if(...) {...}? Or for not integrating matchers with for() at all?

If you want to introduce bindings, you could just do:

I'm sorry, but I need to make sure: was this suggestion made in earnest? Because it is extraordinarily verbose and circuitous for such a simple and straightforward use-case.

Again I ask: what is separating destructuring and matching that makes let <destructurer> = x; acceptable and readable, but let <matcher> = x bad?

tabatkins commented 1 year ago

or just use is:

While this might be workable, it gives us a totally different syntax pattern than let ...;, which is unfortunate given how the other constructs look like their normal selves. It does let us do let/const as the variable binding keywords without questions about what they mean if the let/const statement disagrees, tho. (But there are other possible keywords for this, like as, which we could also use, defaulting to a let semantic but allowing the let/const/var statement override that.)