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

Is there too much syntax magic with identifiers and matchers? #210

Closed
theScottyJam opened this issue Jul 12, 2021 · 20 comments
Closed

Is there too much syntax magic with identifiers and matchers? #210

theScottyJam opened this issue Jul 12, 2021 · 20 comments

Comments

@theScottyJam
Copy link
Contributor

Consider the following pattern match

when({ x: val, y: 'abc' })

What comes after the : has one of two very different meanings, depending on the type of expression found there:

  • If it's a special, pre-selected literal, or if it's a pinned expression, then match against it.
  • If it's an identifier, then assign to it.

We've gotten used to this behavior from other languages that implement a pattern matching syntax, but I argue that it's not a healthy one. In fact, there's a lot of magic to it that steepens the learning curve to pattern matching, and thus to the overall language. It would be as if I made a proposal to introduce assertions using the following syntax: 'abc' = myVariable - that's absurd! We're overloading assignment to mean two different things depending on what's found on the LHS, yet we've convinced ourselves that it's ok to do that with pattern matching, because there's prior art. (maybe the analogy is a bit of a stretch, but the point still stands)

There's different possible solutions to this issue - the one I'll present here tries to follow the following advice from the README.

The pattern matching construct is a full conditional logic construct that can do more than just pattern matching. As such, there have been (and there will be more) trade-offs that need to be made. In those cases, we should prioritize the ergonomics of structural pattern matching over other capabilities of this construct.

Assigning to a variable in the middle of a pattern-matching expression is not pattern matching, it's just a convenient thing to do while you've got the object's structured opened up. So, lets add a little syntax weight to it, to help disambiguate intentions - all assignments now require the usage of the as keyword.

Our original example would then become:

when({ x as val, y: 'abc' })

Whatever comes after the : will now always be a pattern that you match against. You're not allowed to put an identifier there anymore.

Here's a similar example for arrays

when ([as a, ^(2 + 2) as b, 2])

In a similar spirit to objects, picking out values must be done using the as keyword, to help visually distinguish assignment from matchers.


I expect there will be some push-back on this idea, which is fine, let's discuss the possibilities :). I do think having literals and identifiers in the same place with two different behaviors is odd semantics for a language feature. Maybe you don't agree with the solution, but do you at least agree with the presented issue? This proposed "required to use as" is just one possible fix. I've entertained other ideas too, some of which looked nicer, but I couldn't figure out a consistent way to implement those within both the object and array destructuring syntax, which is why I didn't present them, but maybe others could come up with syntax ideas to better disambiguate these two pieces of functionality.

@ljharb
Copy link
Member

ljharb commented Jul 12, 2021

Pattern matching is much less useful if you can't create bindings from the matched parts. The existence of an irrefutable match - an identifier - is something that's needed for a number of use cases:

  • when ([1, 2, a]) - otherwise you can't bind the third item
  • when ({ a: { b: { c: { d } } } }) - otherwise you'd have to repeat the .a.b.c.d part inside your RHS
  • when ([a, b]) - an iterable with two items, and since iterables aren't reusable, without a binding these items would be lost and irretrievable

@theScottyJam
Copy link
Contributor Author

Pattern matching is still completely possible without binding values to variables:

match (something) {
  when ([1, 2,]) { something[2] }
  when ({ a: { b: { c: { d } } } }) { something.a.b.c.d } // You already noted this was possible, it's just not pretty.
}

const array = [...iterable]
match (array) {
  when ([,]) { array[0], array[1] }
}

The purpose of those examples are simply to illustrate that pattern matching stands alone just fine without binding syntax. Certainly, pattern matching without binding makes a number of tasks more inconvenient, and certainly, I would never advocate that we proceed with this proposal without some sort of binding syntax. But, just because binding is super convenient, does not automatically mean it should have equal footing compared to the rest of the proposal when it comes to the proposal's syntax. I can think of a number of other convenient things we can do in the middle of the pattern matching syntax that could improve the usefulness of this proposal. However, I think the README's clause fits really well here - binding is a useful aspect to pattern matching, but isn't central to it, therefore we're justified in giving it a little syntax weight if it improves the clarity of the entire proposal.

So, the question is, does the suggested syntax weight improve clarity? If not, are there alternative syntax ideas that could be put in place, that mitigate the original issue described?

@ljharb
Copy link
Member

ljharb commented Jul 12, 2021

For objects, yes, but not if something is an iterable - not an array, since like destructuring, the syntax works on iterables.

iow:

function* gen() {
  yield 1;
  yield 2;
}
match (gen()) {
  when ([a, b]) { /* impossible to get 1 and 2 here unless `a` and `b` create bindings */ }
}

@theScottyJam
Copy link
Contributor Author

theScottyJam commented Jul 12, 2021

That was solved by this example:

const array = [...iterable]
match (array) {
  when ([,]) { array[0], array[1] }
}

the README states that pattern matching consumes the entire iterable anyways, so this example should do the trick.

@ljharb
Copy link
Member

ljharb commented Jul 12, 2021

It's one of the top priorities of this proposal to match destructuring, so it's non-negotiable that it match non-array iterables with the [ ] syntax.

@theScottyJam
Copy link
Contributor Author

theScottyJam commented Jul 12, 2021

That's fine. And I haven't broken that concept either (edit: At least in the context of what we're currently talking about). You can still pattern match against non-array iterables without binding to values. It just means you can't use the values from the iterator in the RHS, unless you've turned it into an array first. But the point still stands - pattern matching does not absolutely require bindings to exist - bindings are just a very useful feature to mix in, and the primary purpose of binding is, well, to bind, not to pattern match.

I feel like every time we've talked about that clause in the README, it tends to get interpreted as "whatever is inside this proposal is 'pattern matching', and any foreign suggestions is not", making this proposal pretty inflexible to further adjustments :(. I mean, what if the initial idea for this proposal started with another syntax option, where you could inline arbitrary checks into the pattern matching syntax like this: when ({ x check v => v > 2 }), where if the function returns true, then the x value passes the pattern, and if it returned false then it failed the pattern. No one would be able to ask that we remove this feature, because it's technically "pattern matching", and the proposal must prioritize pattern matching over everything else.

So, I still don't think that README clause has any power to auto-veto the idea of adding a small syntactic burden to bindings. If you personally don't like the proposed syntax, or if you don't want to add a syntactic burden to bindings, that's a different story that can be discussed - I expected that people might not like the proposed syntax. But, we can at least give the idea a fighting chance, and not just kick it out for being different from the current proposal.

@tabatkins
Copy link
Collaborator

The issue is weighing different sorts of confusion against each other. In particular, the current syntax biases toward acting like destructuring when it matches destructuring's syntax: [a,b,c] does extremely similar things as both a pattern-match and a destructuring pattern (grabs the first three values from the iterable and binds them to a, b, and c), and the same applies to object patterns and object destructuring.

Switching to an [as a, as b, as c] pattern-syntax is certainly possible, but it means we diverge from a common syntax in those two similar situations, which is somewhat unfortunate. It also brings up a difficult question of what [a,b,c] means as a pattern - it either has to be disallowed, as a/etc isn't a pattern, or it has to implicitly switch you into expression-syntax (aka the pin operator context). If the latter, now we have some more potential confusion, as a number of pattern-syntax constructs are also valid expressions, and authors have to recognize when a particular bit of syntax, like an object literal, is a pattern or an expression. (And not all object-literals are worthless for matching; an object-literal can be defined with a [Symbol.matcher] key, for instance, to trigger the custom matcher protocol.)

The fact that some literals play double-duty as patterns, and thus behave identically whether they're pinned or not, definitely is slightly inconsistent. But it's a harmless sort of inconsistency that we don't believe will cause confusion.


That was solved by this example:

It's not; that example code only "solves" things if the iterator is the top-level pattern. If it's nested, you can't reasonable adapt it.

@theScottyJam
Copy link
Contributor Author

theScottyJam commented Jul 12, 2021

It's not; that example code only "solves" things if the iterator is the top-level pattern. If it's nested, you can't reasonable adapt it.

Fair point. I still don't think it makes binding a central feature of pattern matching that we're not allowed to add extra syntax weight to though.

It also brings up a difficult question of what [a,b,c] means as a pattern

A syntax error. Either do [as a, as b, as c] or [^a, ^b, ^c]`.

Switching to an [as a, as b, as c] pattern-syntax is certainly possible, but it means we diverge from a common syntax in those two similar situations, which is somewhat unfortunate.

This is probably my primary concern with the solution I proposed and is the reason why I didn't expect a lot of love for it (the fact that there's deviations between the syntax for destructuring and pattern matching) :p. There may be other solutions to the problem I described, but that one was the best I could think of, which is why I was hoping to open this issue up as a way to explore the problem with different possible solutions and see if the trade-offs are actually worth it. It could be that there's no good solution to this problem.

But, just so we can open up our perspective a bit, here's another possible solution that actually preserves the relationship with object destructuring - this solution also doesn't create an extra syntax baggage for bindings like my original one. When matching an object, we can use a matches operator (like ->) to distinguish pattern matching from destructuring, like so:

when({
  a, // a must exist and gets bound to a.
  b: B, // b must exist and gets bound to B
  c -> 2, // c must exist, gets bound to c (maybe?), and is pattern matched against 2
  d: D -> ^(2 + 2), // d must exist, gets bound to D, and is pattern matched against `2 + 2`
})

I really like how that looks, but the issue is that there's not an easy way to translate this idea to arrays. The obvious solution would be something like when([a, -> 2]) (where -> is used before any pattern), but I was afraid that if I suggested that, then that README clause would get thrown at me, because I'm adding extra bloat to pattern syntax. And, I might agree if that did happen, using a lot of values, a -> before each pattern can quickly become ugly.

But, there's more than one way to solve the issue. I just don't know yet if there's any good ways to do so.

@tabatkins
Copy link
Collaborator

tabatkins commented Jul 12, 2021

That still breaks destructuring patterns, or if it doesn't it's inconsistent:

when( {a: {b}} ) ...

(If I'm understanding your suggestion correctly, you'd have to write this as {a -> {b}}.)

If we want to keep things consistent with destructuring, it means we must keep ident matchers and array/object matchers in the same syntax space. If we abandon that, we need a pretty good reason to do so, since we'll be breaking with very reasonable author intuition.

@theScottyJam
Copy link
Contributor Author

theScottyJam commented Jul 12, 2021

No, sorry, I wasn't very clear. You would still do when( { a: { b } } ). It can almost be thought of as follows:

Inside when() you destructure an object. If you're unable to destructure the object (e.g. a particular, expected attribute was missing), then the pattern will fail. While destructuring, we've enabled an extra syntax option, ->, to allow you to do extra checks on a single value, which, if fails, then the whole pattern fails.

I know that explanation isn't complete, but it gives a good reason for why when({ a: { b } }) uses a : and not ->, because we've just destructuring (and treating the whole destructuring syntax as a pattern), and we're doing little extra pattern matches against specific values using -> as we destructure.

Update: So, I shouldn't have said that -> is used for patterns, because, like you said, object destructuring is a pattern. I should have said -> is used for leaf patterns. Destructuring syntax stays the same.

@ljharb
Copy link
Member

ljharb commented Jul 12, 2021

Destructuring creates a binding already - what further explanation is needed when a destructuring pattern does the same?

@tabatkins
Copy link
Collaborator

tabatkins commented Jul 12, 2021

Oh, huh, so you're still allowing ident-matchers and object/array matchers to live together, you're just making primitive matchers and pinned/custom matchers require a -> prefix?

I'm not sure what we particularly get out of this, then. Pin syntax already disambiguates itself from the other patterns, and, save for the theoretical issue of a variable named "undefined", all the literals are also unambiguous.

Your original complaint was about ident matchers having the unique "assign to this name" semantic while the other matchers didn't, so I don't see this addressing that problem at all. In that case, can you restate what problem you're currently having with the proposed syntax?

Edited to add: Since primitive matchers are identical whether pinned or not, effectively this is just saying "remove primitive matchers and just let authors use pinning for this", which isn't an unreasonable position (I mentioned it in my first post in this thread), but it doesn't solve your initial stated issue.

@theScottyJam
Copy link
Contributor Author

theScottyJam commented Jul 14, 2021

Pattern matching currently behaves almost the same as destructuring, but deviates in a number of ways that creates a lot of cognitive overhead for this proposal. My first proposed idea (requiring as everywhere) tried to rectify this problem by creating a larger gap between pattern matching and destructuring in the hopes that it'll be harder to trip up from them being too similar and not the same. I presented it, because it was the only complete idea I had, but it has a number of holes, and I believe now that it was a bad idea to start with. The second idea of adding -> was mainly intended to help create a bigger visual divide between where pattern matching is happening and where simple destructuring is happening. I tried to present a partial version of the -> idea, but I think doing so made for some inconsistencies - The idea of -> makes more sense when paired with another idea that I'll present later on.

So while I do think the original issue I brought up in this thread is important (and I'll touch on it more later on), maybe the broader problem I've been trying to untangle is the fact that there's a disconnect between pattern matching and destructuring.

So, let me start over.


The problem is that there's a disconnect between pattern matching and destructuring.

I've been digging around through previous discussions in the github issues, and have found a couple of old issues where people have previously shared some of the same issues I've been trying to rectify. I'll start out by enumerating them:

  1. You're allowed to destructor a property that does not exist on an object (it gets set to undefined), but pattern matching fails if a property does not exist - see this issue.
  2. Pattern matching will fail if the number of elements in a given array does not match the amount given in the pattern, but this works just fine with destructuring. See this comment.
  3. The fact that you're sometimes required to use the "as" keyword to bind a value to a target when pattern matching, but not when destructuring shows that something fishy is going on.

Solutions

I'm going to go ahead and present a complete solution using the -> syntax I presented previously, along with another syntactic additions.

For issue #1 and #2, I'm going to take a leaf out of the Object freeze/seal syntax proposal here. If it's implemented, then it would become possible to have destructuring syntax as follows:

const {| a, b |} = myObj
const [| a, b |] = myArray

In the above example, if myObj did not have an a property, or if it had an additional c property, then an error would get thrown. Similar rules apply for the array version. If you wanted to ensure certain properties existed, but did not care if extra properties did too, then you would use the spread operator.

const {| a, b, ...rest |} = myObj
const [| a, b, ...rest |] = myArray

With this in place, we can add some consistency to the pattern matching proposal.

match (data) {
  when({
    x, // Just like with destructuring, x does not have to exist on data for this to match
    y -> 2, // y has to equal 2
            // (I'm using the same arrow syntax here that I've explained previously
            // if I wasn't, then this would be `y: 2` instead).
    z -> undefined, // z could either be explicitly set to undefined, or it might not exist,
                    // in which case, it'll be set to undefined by default, just like with destructuring.
  }) { ... }

  when({| // Notice our use of {| ... |} - we're now destructuring a "sealed bag"
    x, // x must exist
    y -> 2, // y has to equal 2
    z -> undefined, // z must exist, and must equal undefined
    ...etc // The rest param must be used if we want to allow this object to contain other properties
  |}) { ... }
}

You can imagine a similar principle for array destructuring.

You'll notice that the only pattern-like feature that destructuring brings is the fact that if it normally would have thrown an error, it fails the pattern instead (that's a very easy to remember rule-set!). This sort of removes the concept of an irrefutable match. It can be emulated with [|x|], but when we do that, we don't imbue that identifier with special pattern-matching powers (like the current proposal does), instead, that pattern would fail if we supplied an empty array, simply because the destructuring failed.

Edit: I'll also note that this cleanly fixes a previous concern brought up here, which is the fact that in the current proposal, there isn't a way to match against an empty object. Well, now there is, use {| |}! The o.p. of that issue also noted the inconsistency we have with how we handle extra elements in objects vs arrays - in objects, extra elements are allowed, in arrays, they're not. @tabatkins did bring up some good points here that were directed at that particular issue, but many of those points also apply with what I'm proposing here. We can discuss those points further if wanted, but I'll note that I'm simply piggy-backing off of the semantics of the seal/freeze syntax proposal, so some of those issues brought up are issues with the seal/freeze syntax proposal itself, not with how it's being used in pattern matching.

Now to address issue #3 - the fact that we're sometimes required to use as in the middle of our destructuring pattern to bind a value, even though as isn't needed in normal destructuring.

Compare these two scenarios:

when ([('N' or 'S') as dir]) { ... }
const [dir] = ...

The inconsistency here is in the fact that when destructuring, you can bind to a variable by simply putting the variable name there, while in the pattern matching version, if you're wanting to use a pattern in that slot, then you've shadowed your ability to bind that value to a variable. So, what's the fix? We've introduced an as keyword to allow you to bind anyways, in a second-class way.

In other words, in an effort to prioritize pattern matching syntax, we've added a new operator to do something that should already be possible if pattern matching was really just extra syntax on top of destructuring. This is also the crux of the original issue I did a poor job of explaining. My first post mentioned that I didn't like how in the example when({ x: val, y: 'abc' }), whatever comes after the : could mean different things depending on what type of thing we put on the RHS, but I wasn't completely sure why I disliked it, I just knew this was part of what was leaving a bad taste in my mouth about this proposal. Well, now I've figured out that a major reason for my dislike is that it forces this inconsistency between destructuring and pattern matching.

Notice that the -> syntax solves all of this. We can rewrite our example above as when ([dir -> 'N' or 'S']). This format lets the destructuring syntax handle the binding for us. It completely removes the need for this extra as binding operator, except for the when ([{ x } as val]), which only remains useful because object destructuring also lacks the ability to create bindings from intermediate levels (i.e. const [{ x } as val] = ... would be a useful feature as well). If we want the ability to do when ([{ x } as val]), we should add it to the destructuring syntax, and let pattern matching naturally reap the benefits of it.

Some -> examples:

when ([dir -> 'N' or 'S']) { ... }
when ([-> 'N' or 'S']) { ... }
when ([dir]) { ... }
when ({ dir -> 'N' or 'S' }) { ... }
when ({ dir: DIR -> 'N' or 'S' }) { ... }
when ({ dir }) { ... }

Phew - it took me a couple of days to work through my thoughts, research what others have said, and figure this all out. But I think this sums up the real problem I'm hoping to solve. I know we've talked previously about how we don't want to add extra syntactic baggage to leaf patterns, so syntax like when ([-> 'N' or 'S']) is a no-go. But, maybe we should reconsider - after all, the fact that we currently allow leaf patterns to be placed in the same location as a binding identifier is what creates the inconsistency that forces us to have an as operator, to do something that destructuring syntax should already be capable of doing. In general, if we actually value the desire to build on top of destructuring's syntax and semantics, then I think we're going to need some sort of solution akin to the ones I've described above.

@theScottyJam
Copy link
Contributor Author

theScottyJam commented Jul 14, 2021

Now to specifically address your concerns @tabatkins

Oh, huh, so you're still allowing ident-matchers and object/array matchers to live together, you're just making primitive matchers and pinned/custom matchers require a -> prefix?

Try looking at it this way. When pattern matching, we may do some object/array destructuring, which if it fails, the whole pattern fails (we're not imbuing array/object destructuring with other pattern matching abilities like we previously were doing). As previously explained, irrefutable patterns are more of an emergent principle of pattern matching now - when you match a zero-length array against [|x|], it fails because the destructuring would have failed, not because we've imbued the x identifier with special pattern-matching powers like the current proposal does. Because of this, I don't really consider irrefutable matches a leaf pattern anymore (I'll use the term non-binding leaf pattern whenever I want to make this distinction), instead, irrefutable matches is just part of normal destructuring behavior. Everything described thus far is just destructuring, and everything else (pattern-matching against literals or pinned expressions) is extra stuff we're trying to add to the destructuring syntax, and will now require the -> operator to disambiguate.

Since primitive matchers are identical whether pinned or not, effectively this is just saying "remove primitive matchers and just let authors use pinning for this", which isn't an unreasonable position (I mentioned it in my first post in this thread), but it doesn't solve your initial stated issue.

This was not my intention. I've already tried to advocate that here and failed :p. My thoughts were that after the -> you would still use the pin operator for expressions, and specific literals are still allowed to be special-cased, and not require the pin operator. But - now that you've mentioned it, the -> operator has created perfect conditions to make this happen, and we would be able to toss the pin operator.

I'm not sure what we particularly get out of this, then. Pin syntax already disambiguated itself from the other patterns, and, save for the theoretical issue of a variable named "undefined", all the literals are also unambiguous.

We get rid of "as", the pin operator, and the list of special cased literals - that's a lot of simplification. I see all of those things as hacks that we've layered on, because we're trying to push expression patterns into the same place where identifiers go, and they just don't fit there, so we've created all of this extra stuff to make it possible to disambiguate things.

Pros of the complete proposal presented above

  • We can get rid of as, the pin operator, and special cased literals, replacing all of that with ->. (if we want to address the one remaining use case for "as", we should do so in the destructuring syntax, and let pattern matching naturally inherit the benefits of it).
  • We can remove the three stated inconsistencies between pattern matching and destructuring.
  • The idea of irrefutable matches becomes an emergent principle instead of extra magic that we've cooked into destructuring.
  • Edited to add: We've enabled more pattern-matching abilities, such as the ability to match against an empty object.

Addressing potential arguments against this idea

  • Doesn't the -> in arrays add too much extra verbosity?:

    I would argue that we've already overstepped what we're allowed to do when we started allowing bare, non-binding leaf patterns where destructed identifiers go. In the name of reducing verbosity, we've tried to force (non-binding) leaf patterns into the same place as bindings, but couldn't fit it all in, because of conflicts with bindings, so we created a bunch of special case literals that we allow there instead, and we invented the pin operator to distinguish everything else. But now we've got the issue that it's useful to both bind and pattern match at the same time, so we've invented the as operator to give you back the ability to bind that we just took away. The -> idea is just taking things one step back, and fixing the inconsistency and syntax issues we dug ourselves into by our attempts to make pattern matching too concise. This proposal is already introducing a whole lot of new syntax to the language (could it be on par with the amount of syntax that classes introduced?) - let's do what we can to reduce the syntax budget we're spending.

@ljharb
Copy link
Member

ljharb commented Jul 14, 2021

@theScottyJam ok this is suuuuper long to respond to, so hopefully i'm not missing anything :-)

The problem is that there's a disconnect between pattern matching and destructuring.

This is true, and also unavoidable, since they're for different purposes. The goal is to extend destructuring in a way that's intuitive for pattern matching. They need not be identical, and couldn't be.

Points 1 and 2 are valid, but I think they're perfectly sensible and expected here.

The fact that you're sometimes required to use the "as" keyword to bind a value to a target when pattern matching, but not when destructuring shows that something fishy is going on.

I don't think this is fishy at all - anything that can be a variable will be bound as that variable, much like if foo was shorthand for foo as foo. Anything that can't be (like any non-identifier pattern) simply won't be bound, unless you use the long form like <pattern> as foo.

in the current proposal, there isn't a way to match against an empty object.

when ({ ...x }) should absolutely match against an empty object.

@theScottyJam
Copy link
Contributor Author

@theScottyJam ok this is suuuuper long to respond to, so hopefully, i'm not missing anything :-)

Yeah, it did get really long :p. But you addressed the main points.

I'm curious @tabatkins if you have any opinions on this matter too?

@tabatkins
Copy link
Collaborator

(lots of individual responses to particular points, and a tl;dr at the end)

The fact that you're sometimes required to use the "as" keyword to bind a value to a target when pattern matching, but not when destructuring shows that something fishy is going on.

You use as to bind a value in spots that destructuring specifically does not allow (either things pattern-matching does that have no equivalent in destructuring, like custom matchers, or when you're binding an intermediate value that you want to pattern-match further on). It's not used for anything shared with destructuring. (And, tho the champion group hasn't met in a while to resolve this, we should be able to remove it entirely and instead rely on ident matchers and the and operator.)

In the above example, if myObj did not have an a property, or if it had an additional c property, then an error would get thrown.

Afaict, "if it had an additional c property" is not at all what the sealing-syntax proposal is doing; it would be impossible to reasonably do for the same reason that our object-matcher syntax doesn't have an exclusive version, either: destructuring, like pattern-matching, will walk up the prototype chain, so the notion of "an extra property" will virtually always be satisfied.

This also means this doesn't help match against an "empty object" (because there's no reasonable notion of such that's compatible with how destructuring or pattern-matching need to otherwise work).

In other words, in an effort to prioritize pattern matching syntax, we've added a new operator to do something that should already be possible if pattern matching was really just extra syntax on top of destructuring.

Note that we never claimed it was just extra syntax on top of destructuring. Our claim was and is that pattern-matching is compatible with destructuring as much as possible, such that destructuring patterns should be valid pattern-matchers with nearly identical behavior (the only divergence being that we actually do check the value matches the specified structure, rather than just allowing undefined to get thrown around). It should feel the same as destructuring, and I believe it does.

Notice that the -> syntax solves all of this. We can rewrite our example above as when ([dir -> 'N' or 'S']). This format lets the destructuring syntax handle the binding for us.

Okay, so afaict this is just inverting the order of patterns vs bindings. Rather than pattern or pattern as name, you write -> pattern or name -> pattern.

Your example doesn't show further nesting of array/object matchers, but given your stated motivations, I assume those would not require the -> prefix, so you could write when ([{x}, b]) -> ... to ensure the first item in the array is an object containing an "x" property (and bind it), right? (Otherwise this would immediately diverge from destructuring.)

If so, this then, eh, this doesn't really buy you any more consistency, and might actually reduce it.

  • If you're not binding intermediate values to a name, then for object and array matchers both our proposals are identical, while for the other matchers (literals, compounds, customs) your proposal pays a small syntax tax (a -> prefix)
  • If you are binding intermediate values to a name, then for literals/compounds/customs our proposals are identical, just with the name and pattern in swapped positions and with a different separator (-> vs as). For object or array matchers, it looks like you're proposing we get as syntax added to destructuring, and lean on the same syntax (whatever it ends up being).

Both of these seem not great to me! In the first, we expect literal-matchers to be very common, particularly with strings or numbers, and so the additional -> prefix is a little more tax everyone is constantly paying, and feels a little confusing because there's nothing on the left side of the arrow.

In the second, we have two completely different ways to bind intermediate values to names, depending on what kind of pattern we're descending into (and one isn't even defined yet, and relies either on the TC passing a new proposal, which is unbounded in time, or on the TC being comfortable binding its future self to use the same syntax we decide on here if they later add intermediate-value bindings to destructuring).

The overall result is that this proposal stays slightly closer to destructuring syntax in some situations where we're extending past what destructuring can do (like when ({ dir: DIR -> 'N' or 'S' }) { ... }), but sacrifices some terseness and consistency between patterns to do so.

I know we've talked previously about how we don't want to add extra syntactic baggage to leaf patterns, so syntax like when ([-> 'N' or 'S']) is a no-go.

Notably, the thing on the RHS of the -> does not have to be a leaf pattern (it could be, say, -> ("N" or {dir:"N"}). This might be leading to some talking-past of each other, as you might be optimizing toward the assumption that there isn't further matching to be done, which might include more object or array matchers.

As previously explained, irrefutable patterns are more of an emergent principle of pattern matching now - when you match a zero-length array against [|x|], it fails because the destructuring would have failed, not because we've imbued the x identifier with special pattern-matching powers like the current proposal does.

We're not imbuing identifiers with special powers. It fails in our proposal because the array matcher has special powers slightly exceeding that of destructuring, where it checks the value's length against its pattern length.

I think this would actually be a very bad switch to make for several other reasons, tho:

  • This is pinning a core and vital part of the pattern-matching proposal on a (afaict) Stage 0 proposal, and in particular on a sketch of a potential extension to said proposal. This would effectively kill pattern-matching for the time being, and possibly permanently.
  • This implies that if you aren't using seal-literal syntax, then you don't get these checks, so the shorter/easier way to write these patterns is going to do the thing that people will (imo) almost never want.
  • The "emergent"-ness of the behavior is, imo, actually a strong footgun. A pattern like [->200] would fail to match a zero-length array (because the 0th index returns undefined, which is not 200), but then if you realized you can handle multiple cases together in a generic way and change it to [x], it'll suddenly start matching zero-length arrays (and binding x to undefined when it does).
  • We get rid of "as", the pin operator, and the list of special cased literals

We don't get rid of as, since afaict in your proposal we'd still need it for binding intermediate values and then continuing with an array or object matcher. We also need it for custom matchers, to let you bind either the value before custom-matching, or the value produced by the custom matcher after.

We don't get rid of the pin operator either, at least not entirely - we need something to let us switch into "expression mode" so we can resolve an arbitrary expression into a custom matcher value. (And it needs to be syntactically distinct from the other matchers.)

The "list of special-cased literals" is just "all literal primitive values"; this is spec-complexity only (and then, only barely), not author-facing complexity. Removing it won't make the proposal any simpler for authors, at least not a priori.

  • We can remove the three stated inconsistencies between pattern matching and destructuring.

This doesn't, it just shifts them around. In all cases where the pattern would be a valid destructuring, your proposal and the current proposal are identical in syntax and behavior. This only changes syntax in places that are already not valid destructuring.

(Unless you're referring to things like length-checking, where your proposal relies on seal-literal syntax in destructuring. In which case, see next point.)

  • The idea of irrefutable matches becomes an emergent principle instead of extra magic that we've cooked into destructuring.

As I argued above, I actually think this ends up being a very bad aspect of this proposal. I'd fight very strongly against this bit.

  • Edited to add: We've enabled more pattern-matching abilities, such as the ability to match against an empty object.

As I argued above, we haven't; the seal-literal syntax doesn't (and can't) work like that.


In conclusion, I find that I'm ambivalent to weakly opposed on the part of your proposal that introduces -> and rearranges the order of clauses slightly. It doesn't increase overall consistency, and may actually reduce it (arguable, but defensible, I think). I'm strongly opposed to the part of your proposal that suggests relying on the sealing-literal syntax. It would be blocking our proposal on a sketched possible extension to a stage 0 proposal with uncertain support in the first place, and it would make the non-seal-literal version of object and array matchers actively harmful for authors to use in pattern matching.

@theScottyJam
Copy link
Contributor Author

Wow @tabatkins - what I thought was an idea with some potential now looks like swiss cheese - you blew all sorts of gaping holes into it.

Afaict, "if it had an additional c property" is not at all what the sealing-syntax proposal is doing; it would be impossible to reasonably do for the same reason that our object-matcher syntax doesn't have an exclusive version, either: destructuring, like pattern-matching, will walk up the prototype chain, so the notion of "an extra property" will virtually always be satisfied.

Oops, you're right, that's not what that proposal is doing 😳️.

And I didn't think about this prototype tying - I doubt there's a reasonable way to get around that without making more inconsistencies.

Note that we never claimed it was just extra syntax on top of destructuring. Our claim was and is that pattern-matching is compatible with destructuring as much as possible, such that destructuring patterns should be valid pattern-matchers with nearly identical behavior (the only divergence being that we actually do check the value matches the specified structure, rather than just allowing undefined to get thrown around). It should feel the same as destructuring, and I believe it does.

Maybe you didn't claim that (and that's not the current goal), but trying to get as close as reasonably possible to having "pattern matching be extra syntax on top of destructuring" would make this proposal easier to bite off and chew. So, the three issues I'm pointing out are mostly about trying to bring it closer to the "extra syntax on top of destructuring" idea. Each divergence, like "check the value matches the specified structure, rather than just allowing undefined to get thrown around" makes pattern matching much harder to pick up and learn, and leads to sticky questions, like how to deal with default assignment - there are valid consistency related arguments for both sides of that issue, we can either be consistent with how we've diverged (and have non-existent properties fail the pattern match), or be consistent with destructuring (and have non-existent properties receive a default value). The answer to this will become another thing that developers will have to learn related to pattern matching.

We create a source of bugs and "another thing to remember" any time we create some syntax that could reasonably have two different meanings, like, default assignment within pattern matching. This proposal seems to have a lot of those scenarios - but as I'm finding out, it's really, really difficult to get rid of them.

Okay, so afaict this is just inverting the order of patterns vs bindings. Rather than pattern or pattern as name, you write -> pattern or name -> pattern.

technically true, but there's a conceptual difference, which is:

  • You tack as ... onto something when you want to bind the LHS's result to the RHS
  • You tack -> ... onto something when you want to add a matching assertion to the LHS

OK, maybe that's a weak argument - but it felt more right to me 🤷‍♂️️

Your example doesn't show further nesting of array/object matchers, but given your stated motivations, I assume those would not require the -> prefix, so you could write when ([{x}, b]) -> ... to ensure the first item in the array is an object containing an "x" property (and bind it), right?

You are correct

Notably, the thing on the RHS of the -> does not have to be a leaf pattern (it could be, say, -> ("N" or {dir:"N"}). This might be leading to some talking-past of each other, as you might be optimizing toward the assumption that there isn't further matching to be done, which might include more object or array matchers.

Umm, alright, this absolutely destroys the -> idea. If an object pattern can come both after a : or ->, then -> is doing a really bad job at making any sort of distinction between things.

This is pinning a core and vital part of the pattern-matching proposal on a (afaict) Stage 0 proposal, and in particular on a sketch of a potential extension to said proposal. This would effectively kill pattern-matching for the time being, and possibly permanently.

I'm mostly suggesting that some features may work better if shared between both pattern matching and destructuring. I wouldn't be surprised if that proposal doesn't get very far (it's looking like records/tuples may devalue it's objectives), and wouldn't want to pin the success of this proposal on a side feature of that one, but we could still find certain principles that both pattern matching and destructuring can benefit from. It could even be done in a way where we first add them to pattern matching, then later add them to destructuring.

  • This implies that if you aren't using seal-literal syntax, then you don't get these checks, so the shorter/easier way to write these patterns is going to do the thing that people will (imo) almost never want.
  • The "emergent"-ness of the behavior is, imo, actually a strong footgun. A pattern like [->200] would fail to match a zero-length array (because the 0th index returns undefined, which is not 200), but then if you realized you can handle multiple cases together in a generic way and change it to [x], it'll suddenly start matching zero-length arrays (and binding x to undefined when it does).

Yeah ... so while it solves some of these issues I brought up, it does make default behavior not as nice, and maybe even turns defaults into footguns :(.

I guess one solution is to make it so you're required to use [| ... |] when matching against arrays, and { ... } with objects, and other options would be a syntax error. In other words, we only allow a subset of valid destructuring to prevent syntax errors, but we still force the code to explicitly show what kind of matching is going on instead of magically choosing defaults that the code authors have to remember. But, that's a pretty gross solution.

The "list of special-cased literals" is just "all literal primitive values"; this is spec-complexity only (and then, only barely), not author-facing complexity. Removing it won't make the proposal any simpler for authors, at least not a priori.

Not quite, template literals are an exception and yet another thing for code authors to remember. @ljharb mentioned multiple valid ways to work with them in this issue - multiple options seem valid, and whichever one we choose may not be the one that a particular developer expected.


Alright, I think you've thoroughly destroyed my -> idea as a solution to issue #3 and I doubt I can come up with another solution for it - it also seems to be an issue that mostly exists in my head, so I'll drop it (unless I can think of anything else to help it in the future).

The object destructuring + prototype argument destroys any hope of resolving #2, unless we implement the gross idea of forcing users to use the [| ... |] syntax for array and { ... } syntax for objects, or something like that.

I think issue #1 may still have some hope though: "You're allowed to destructor a property that does not exist on an object (it gets set to undefined), but pattern matching fails if a property does not exist".

I know I'm not the only one who's worried about this, as this other issue brings up the same issue, along with a different way to solve it - you're required to use a "!" any time you want to make a binding into an irrefutable match. I've pulled this issue into so many directions, that I'm thinking about just opening a new issue to discuss potential solutions to this one further, like this "!" idea.

@mpcsh
Copy link
Member

mpcsh commented Jul 15, 2021

@theScottyJam I'd strongly encourage you to indeed open some new issues for specific changes you'd like to discuss. I personally found this thread rather difficult to engage with due to the number of entangled moving parts (though Tab hit the points I'd have raised). I think you might benefit from distilling the things you want to propose into individual pieces that stand on their own - it'll make for more fruitful discussion. Thank you for taking so much time to consider the future of this proposal! 💜

@mpcsh mpcsh closed this as completed Jul 15, 2021
@theScottyJam
Copy link
Contributor Author

Yeah ... sorry about that, I really should have split that up into multiple issues.

Here's the new issue to further discuss the remaining point I had.

Thanks, @ljharb and @tabatkins for your time and thoughtful comments, I appreciate it :)

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

No branches or pull requests

4 participants