tc39 / proposal-pattern-matching

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

Binding visibility for template literal patterns #232

Closed mpcsh closed 2 years ago

mpcsh commented 2 years ago

In the current incarnation of the README, we have the following rule:

Interpolation in untagged template literals sees the bindings present at the start of the match construct only.

I'm a bit concerned about this. I could envision some clause like:

match ("Tab Atkins-Bittner") {
  when ((${Name} with [first, last]) & `${first} ${last}`) -> ...
}

This is certainly contrived, but I do think the use-case is important. Thoughts on updating this rule? We should probably flesh out scoping rules as their own section as well...

tabatkins commented 2 years ago

Hm, I mean, I guess? Feels weird that it could see different values than what the guard or RHS will eventually see, tho.

(Well, it could already, since it won't see any bindings from the pattern, but template literals would be able to see multiple distinct values here, depending on their exact placement.)

tabatkins commented 2 years ago

I'm also not happy that they would see different bindings than the interpolation pattern, unless you intended that to scope in the same way as well.

ljharb commented 2 years ago

It wouldn't - in when ((${Name} with [first, last]) & `${first} ${last}`) if (x(first, last)), the guard would see the shadowed bindings from the second pattern, as would the RHS.

tabatkins commented 2 years ago

I'm confused by your response, Jordan; it seems like a non-sequitur. We all agree on what bindings the guard and RHS will see, this is about what bindings might be visible to untagged template literal patterns, or interpolation patterns, when preceding patterns in an & pattern have already established bindings.

ljharb commented 2 years ago

Hmm, maybe I misunderstood your comment.

const first = 'a', last = 'b';
match ('x y') {
  /* assume Name's matcher returns `['x', 'y']` */
  when ((${Name} with [first, last]) & `${first} ${last}`) {
    assert(first === 'x' && last === 'y');
  }
}

Currently, in the above, the template literal pattern would not match, because the pattern would be as if it were 'a b', which doesn't match 'x y'.

The potential change would be that the pattern would match - because the template literal pattern would be as if it were 'x y' - and the first and last bindings would shadow the ones from the with (to no effect in this example, ofc, because they hold the same values).

Did I get that right?

tabatkins commented 2 years ago

Correct, except:

and the first and last bindings would shadow the ones from the with

What extra bindings are you talking about here? There's only the outer bindings, and the ones introduced by with.

ljharb commented 2 years ago

Ah, in the "potential change" scenario, i was assuming the template literal pattern produced bindings; if it doesn't, then strike that part :-)

tabatkins commented 2 years ago

Yeah those aren't patterns in there, just arbitrary JS, so it's not possible to produce bindings from them. ^_^

theScottyJam commented 2 years ago

Some other thoughts:

let value = 2
match (...) {
  when (${value} & value) ...
}

Should this throw an early error? Should the ${value} part not be allowed, because, in the same clause, we're also binding to value, so perhaps this first value should be in a temporal-dead-zone.

tabatkins commented 2 years ago

No, in either case.

In the current proposal text, the templates literal sees the outer binding, no confusion.

In the proposal from this thread, the bindings can potentially change multiple times in a pattern, so TDZing would be pretty restrictive.

ljharb commented 2 years ago

Indeed; this is the realm of linters, not the language, because it's not inherently wrong to do this.

mpcsh commented 2 years ago

We all agree on what bindings the guard and RHS will see

I'm... not sure this is true. Have we fully fleshed out these rules? My intuition is that anything can see bindings that were introduced before it: i.e., reading a clause (which may include many combined patterns and a guard) from left-to-right, any pattern can see any bindings that were introduced to the left of it. Is this wrong or inconsistent with the current proposal?

Related:

Feels weird that it could see different values than what the guard or RHS will eventually see, tho.

Is it different? I thought the inconsistency cut the other way - that a guard could see bindings introduced in the pattern, but a template literal couldn't. I thought my change was resolving that inconsistency.

ljharb commented 2 years ago

@mpcsh not counting template literal patterns, that should be the case.

tabatkins commented 2 years ago

That has not been my intention! My intention is that bindings introduced by patterns are visible to the guards and RHS of the clause, but not visible to interpolation patterns or template literal patterns. (I specified this for template literal patterns, but forgot to say it again for interpolation patterns.)

I don't think it's a good thing if reordering the child patterns of an & can significantly change the meaning of the code, for example. I even more don't think it's a good thing if people write code depending on that.

Is it different? I thought the inconsistency cut the other way - that a guard could see bindings introduced in the pattern, but a template literal couldn't. I thought my change was resolving that inconsistency.

The guard and RHS can see the final set of bindings introduced by the entire pattern. Your proposal allows interpolation to see some set of bindings from the pattern, which may or may not be the whole set, depending on what else happens.

For example:

const log = msg => {[Symbol.matches]() { console.log(msg); return {matched: true}; };
const foo = 1;
match({x: 2, y: {z: 3}}) {
  when(${log(foo)} & {x: foo} & ${log(foo)} & {y: {z: foo}} & ${log(foo)} if(!console.log(foo))
    console.log(foo);

Under your proposal, this'll log 1, 2, 3, 3, 3. In the current proposal (as intended, at least), it'll log 1, 1, 1, 3, 3.

ljharb commented 2 years ago

@tabatkins yes, that's right - but that seems correct to me, since code is read top down and left right, to have bindings become available lexicographically after they're created (especially since shadowing is already left-to-right, so reordering the patterns already changes what bindings show up in guards/RHS)

tabatkins commented 2 years ago

That seems weird to me personally, but I won't fight it if y'all think it's important.

mpcsh commented 2 years ago

I don't have strong feelings here because it's largely an edge case. Personally, if I was a dev approaching this feature without having designed it, I would expect the top-to-bottom, left-to-right logic, but I wouldn't necessarily be shocked if it didn't work like that

theScottyJam commented 2 years ago

Overall, I have a similar sentiment to @mpcsh. I would naturally expect top-to-bottom, left-to-right, similar to how it behaves in destructuring:

> { x, y = x } = { x: 2 }
> y
2

The bindings are introduced as the destructuring-syntax is read from left-to-right, letting you use those bindings in later parts of that syntax construct.

However, the fact that we chose to use ${} for the pin syntax does throw my mental model off a bit. I could see an argument for having all of the value being interpolated via ${} to execute, before the pattern it gets interpolated into to execute.

ljharb commented 2 years ago

cc @Jack-Works @codehag @DanielRosenwasser @rkirsling what are yalls thoughts here?

If nobody objects, I'd prefer we go with "template literals see bindings introduced lexically before them", but we can wait for the next champion call if it needs further discussion.

Jack-Works commented 2 years ago

I'm ok with either side