Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

We should reframe the approach here #281

Closed
codehag opened this issue Jul 18, 2022 · 64 comments · Fixed by #293
Closed

We should reframe the approach here #281

codehag opened this issue Jul 18, 2022 · 64 comments · Fixed by #293

Comments

@codehag
Copy link

codehag commented Jul 18, 2022

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
Copy link
Member

ljharb commented Jul 18, 2022

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
Copy link
Author

codehag commented Jul 18, 2022

thats not true.

@ljharb
Copy link
Member

ljharb commented Jul 18, 2022

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
Copy link
Member

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
Copy link
Member

rkirsling commented Jul 18, 2022

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:

  • I adore the let ... when idea. when ... with made me feel like "oh no, there's a with now? do we really need this too?", but this reveals that my apprehension was not about functionality but rather presentation—we were just racking up too many brand-new constructs at once.

  • I agree with you in having no specific desire to "kill switch"—expanding it would seem okay to me, if we had a plan for how it would interact with case. Otherwise, the key benefit to match seems to be that it would be an expression (but then there's also do { switch {} }).

  • Your regex example addresses a complaint I had! The named capture group vs. match result situation feels totally magical in the current README, but making { groups: ... } explicit resolves that.

  • As a relatively minor point, I would want to disallow = when ... though. As I'm seeing it, it shouldn't just be a standalone expression.

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
Copy link

rbuckton commented Jul 18, 2022

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
Copy link
Member

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 tc39/proposal-do-expressions#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
Copy link
Author

codehag commented Jul 19, 2022

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 codehag changed the title This needs to be simplified We should reframe the approach here Jul 19, 2022
@codehag
Copy link
Author

codehag commented Jul 19, 2022

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
Copy link
Member

ljharb commented Jul 19, 2022

@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
Copy link
Member

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
Copy link

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
Copy link

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
Copy link

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
Copy link
Author

codehag commented Jul 29, 2022

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
Copy link

@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
Copy link

rbuckton commented Jul 29, 2022

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
Copy link
Contributor

theScottyJam commented Jul 30, 2022

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:

  • object matching
  • array/iterator matching
  • primitive matching
  • matching against dynamic values (edit for clarification: i.e. the ${...} syntax)
  • binding

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:

  • regular expression matchers - technically, there's no particular reason for this to be in the core. It's occasionally useful, but it's not foundational.
  • The custom matchers protocol and with chaining
    • built-in matchers - This proposal can depend on "custom matchers protocol", which in turn depends on the core. Note that since it's in the same epic, it's guaranteed to be released with the custom matchers protocol, which means we don't have to worry about it making breaking changes.
  • combinators (and & or)

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
Copy link

@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
Copy link
Contributor

theScottyJam commented Jul 31, 2022

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
Copy link

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
Copy link
Contributor

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
Copy link
Author

codehag commented Aug 1, 2022

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
Copy link
Contributor

theScottyJam commented Aug 1, 2022

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
Copy link

rbuckton commented Aug 1, 2022

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
Copy link

rbuckton commented Aug 1, 2022

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
Copy link

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
Copy link

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
Copy link
Collaborator

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
Copy link

rbuckton commented Mar 28, 2023

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
Copy link
Collaborator

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
Copy link

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link

rbuckton commented Mar 31, 2023

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
Copy link

Or even better:

assert(val is Point(let x, let y));

@tabatkins
Copy link
Collaborator

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
Copy link
Collaborator

tabatkins commented Mar 31, 2023

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

@CadenP
Copy link

CadenP commented Mar 31, 2023

for(...) if(...) {...} is currently usable syntax, capable of skipping items that don't pass the condition in the if without breaking the loop. while(<condition>) breaks the loop, and for(...; <condition>; ...) breaks the loop as well.

Is for(let x of items if <test>) supposed to break the loop or skip the item?

@tabatkins
Copy link
Collaborator

I described the behavior in #281 (comment) (and alluded to it #281 (comment) with talk of Python's list comprehensions). It would skip the item.

(I didn't grok that for(...) if(...) {...} was already valid syntax, but you're right, it is. Phew, braceless blocks are really a trip sometimes.)

@tabatkins
Copy link
Collaborator

Just carrying some conversation over from the chat room, because Ron needs to be on vacation and stop responding but this is good to store for later response:

(from @rbuckton )

The let { x, y } = example is verbose, yes, but uses what could otherwise be regular js in the initializer.
In a way it's meant to illustrate that a let statement isn't a good fit for pattern matching itself, because matching is conditional and let is not.
The closest you get to a useful example is the assert one, because you need to validate the condition was successful.

This is simply wrong - let is not unconditional. (Or at least, let with destructuring isn't.) let {foo} = undefined; throws a runtime error! let {foo} = {} executes successfully (binding undefined), but let {foo: {bar}} = {}; again throws. The destructuring behavior matches what you'd get if you instead wrote a series of assignments, each digging into the value with an appropriate dotted/bracketed path, and that can def fail.

Matchers can just fail a little earlier - let when {foo} = {}; would throw because the match failed. But I don't think that's a difference that makes a difference here.

@tabatkins
Copy link
Collaborator

tabatkins commented Mar 31, 2023

Since the immediate reaction to my suggested syntax changes a few days ago was dislike of the increased verbosity, here's a new draft.

Quick summary:

  • Matchers are identical to what's in the repo today, except I added "predicate matchers" (if(<boolean-expr>)) and extractor matchers (foo(...) and foo{...}, which are shorthands for ${foo} with [...] and ${foo} with {...}).

  • Slightly simplified match() - it still uses a when prefix on each arm, but it's just a keyword rather than a wrapper, to be consistent with the additional stuff. Also dropped the if() part, since predicate matchers exist now.

     let x = match(val) {
     	when <matcher>: <return-val>;
     	when <matcher>: <return-val>;
     	default: <return-val>;
     };
  • Added <val> is <matcher> binary operator. Evaluates to true/false based on value matching the matcher.

  • Extended var/let/const, if(), for(), while(), catch(), and function args with matchers, using a much lighterweight syntax:

     let when <matcher> = x; // throws if match fails, otherwise exposes bindings.
     	// bindings use the specified semantic (var, let, or const).
     	// In all other locations they use "let" semantics.
     if(when <matcher> = x) {...} // executes body if match succeeds, with bindings.
     for(when <matcher> of x) {...} // executes body if match succeeds, with bindings.
     while(when <matcher> = x) {...} // executes body while match succeeds (breaks when it fails)
     catch(when <matcher>) {...} // executes body if catch succeeds, goes to next catch() if not
     	// re-throws if all catches fail
    
     function foo(when <matcher>, x when <matcher>, y = default when <matcher>) {
     	// `when <matcher>`: throws if matcher fails, exposes bindings to body
     	// `x when <matcher>`: throws if matcher fails, exposes bindings to body, also binds arg to x
     	// `y = default when <matcher>`: exposed bindings to body, also binds arg to y. 
     	//		Doesn't throw on failure, just binds y to default instead.
     	// `x` and `y` here can be destructuring patterns, as usual for arguments.
     }

The function arg syntax is the one I'm still most unsure about, it could probably still use some fiddling for maximum readability. Possibly the is operator could be fiddled with more, too - not married to the name, and the argument order is opposite how matchers are used elsewhere.

The rest, tho, are I think minimal, clear, and importantly, very consistent and predictable.

@tabatkins
Copy link
Collaborator

Been fiddling with my draft proposal for "matchers everywhere" more, based on feedback from several people.

Major changes from above:

  • defined how matchers can be used in destructuring patterns, as suggested by Jordan.
    • this replaces the special handling of let/const/var, for(), and function args - their behavior falls out of the fact that they allow destructuring. (if(), while(), and catch() still require some special handling.)
  • added <ident> when <matcher> to explicitly handle "name this chunk and test it more", in a consistent way with the destructuring syntax
  • switched regex matchers to /foo/ when <matcher> for consistency, and dropped the named-capture-groups bindings for simplicity. (You can get standard capture group with when [_, first, second], or named groups with when {groups: {groupname}}
  • Separated ${} syntax syntactically into "test against a variable/dynamic value" (written as ${...}) and "invoke a custom matcher" (written as ${...}(<matchers>), and interpreted identically to the foo(<matcher>) syntax). Now custom matchers are required to return their values as an iterator.
    • As part of this, removed ${...} with <matcher> entirely. It's no longer needed.
  • Defined that foo() and ${foo}() (empty arglists) don't check the custom matcher's result value, just whether it succeeded or failed.

@ljharb
Copy link
Member

ljharb commented Apr 7, 2023

Why would custom matchers be required to return their values as an iterator? That sounds very confusing.

@tabatkins
Copy link
Collaborator

tabatkins commented Apr 14, 2023

Foo(a, b) matches the result of invoking Foo[Symbol.matcher] against [a, b]. If we match up interpolated matchers as ${Foo}(a, b), as I suggest above, then that's also matching the result against [a, b].

The return value can be an array or something; it just needs to be iterable.

(A benefit to doing this, besides the confluence in syntax, is that we no longer need the special "result object". If you return an iterable, that's a successful match; we can also let you return true/false to indicate a successful or failed match. Any other value would be a runtime error.)

@ljharb
Copy link
Member

ljharb commented Apr 14, 2023

Maybe I'm confused. Why would Foo() matching syntax ever invoke the iterator protocol? (separate from user code doing so, ofc)

@tabatkins
Copy link
Collaborator

That's intrinsic to the syntax? It's always been the case, both in the Extractors proposal, explicitly, and in all of my adaptations of extractors into matchers. (Foo(a, b) has always been equivalent to ${Foo} with [a,b].) I'm not sure how you think when Foo(a, b) would work, otherwise.

@ljharb
Copy link
Member

ljharb commented Apr 14, 2023

I hadn't realized that implication, and to me that's a very very strong argument against considering that to be the desugaring. Forcing the iterator protocol when it's not absolutely necessary seems like a very unwise idea.

@tabatkins
Copy link
Collaborator

I have no idea what else the desugaring could possibly be. And this exact desugaring is used by other langs already, like Python's class matchers.

@ljharb
Copy link
Member

ljharb commented Apr 14, 2023

I would expect it to only ever accept one argument, and have that be the pattern tested - so that if you wanted array iterator syntax, you'd type that.

@tabatkins
Copy link
Collaborator

tabatkins commented Apr 14, 2023

That loses the very nice syntax mirroring of function-call/construction <=> matcher pattern, which we have with array and object literals. Again, this sort of matcher syntax is accepted by many existing langs in their matcher patterns, for this reason - that syntax mirroring is pretty attractive.

For example, given a Point object with a constructor like new Point(x, y), giving it a custom matcher that returns the x and y values, and is usable like when Point(x, y): Math.hypot(x, y); is pretty sweet.

Forcing that to be when Point([x, y]) is a lot less attractive, and brings up reasonable questions like "well what're the parens for, then?".

@Jack-Works
Copy link
Member

Maybe I'm confused. Why would Foo() matching syntax ever invoke the iterator protocol? (separate from user code doing so, ofc)

For code

let Foo(a, b) = expr

Foo[@@unapply](expr) will return an array [aVal, bVal], then destructing is happening (a, b) therefore @@iterator is called

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.