Skip to content

Instantly share code, notes, and snippets.

@tabatkins
Last active Sep 3, 2022
Embed
What would you like to do?
Results of the discussion between Tab and Yulia

On Sep 1 2022, Yulia and I had a VC to get past our misunderstandings/disagreements about pattern matching in a high-bandwidth fashion.

The high-level summary is that Yulia has three requests, all of which I think are reasonable. In order of complexity:

Allowing plain functions as custom matchers

Right now, custom matchers have to be done via an object with a Symbol.matcher key. The matcher function can be written as a plain boolean function now, but you still need that object wrapper, which is annoying as it prevents existing boolean functions from being easily used. Ideally you'd be able to write when(${someFunc}) and have it work exactly like when(${{[Symbol.matcher]: someFunc}}) does in today's spec.

The blocker for this is that we currently define Function.prototype[Symbol.matcher] to essentially do an instanceof check, so JS classes can be used by default: when(${MyClass}) will match if the matchable is an instance of MyClass or a subclass, without the author having to write anything specifically. This is because JS classes are just constructor functions, and so the least common superclass of all class objects is Function.

I think we can address this by removing the existing Function.prototype matcher, and instead moving that functionality into a new matcher syntax. I'll provisionally define this new matcher as is ${Foo}, possibly with is Foo working for plain idents. We'll evaluate the expr in ${}, or just lookup the ident, and do the instanceof/brand-checking stuff. Similarly we'd move all the existing built-in custom matchers to this syntax instead, so is Array/etc for brand-checking built-ins. (Potentially we could have another symbol to make this a protocol, but I actually think we shouldn't - this should be a reliable behavior. We can safely change our mind and add one later if we want.)

This then frees us up to define that Function.prototype[Symbol.matcher] just executes the function as a custom matcher function, so when(${someFunc}) works as expressed in the first paragraph.

Benefits:

  • lets authors use existing boolean functions as matchers without excessive syntax
  • makes the "instanceof" behavior reliable - it'll always work the same even if the class author installed a custom matcher on the class as well with some more complex behavior.
    • this frees up classes to do more interesting things, like Number[Symbol.matcher] actually doing a parse and only matching if the value was a number, or stringified to something parseable as a number, while still allowing is Number to do the more simplistic "has the Number brand or is a primitive number" that we currently do.

Cons:

  • one more matcher syntax
  • ?

I'd be happy to accept this into the proposal right now.

Allowing Matching In More Contexts

This was the big point of Yulia's epic - she wants to be able to do all of the following (example syntax):

if(match <pattern> = x) {...}
// passes the `if` only if `x` matches the pattern
// I suppose `if(!match <pattern> = x)` would do the opposite.
// Bindings from <pattern> probably aren't visible outside of it.

if(let x match <pattern> = y) {...}
// as above, but the return value of <pattern> is bound to `x`.
// can also use var/const, of course.
// No negative version, obviously.

if(let match <pattern> = y) {...}
// same as above, but bindings from the pattern *are* visible outside of it,
// using let/const/var semantics as appropriate.


for(let x match <pattern> of iter) {...}
// it the iteration item doesn't match <pattern>, 
// automatically skips the iteration.
// Otherwise binds the <pattern> return value to x.
// Identical to:
// for(let x of iter) { if(!match <pattern> = x) continue; ... }
// Similarly has a `let match` variant that lifts the pattern bindings out.

let x match <pattern> = y;
// if y matches <pattern>, binds the return value to x
// otherwise, binds undefined to x.
// If x is a destructuring pattern, all bindings are set to undefined in case of failure.
// Possible could throw on pattern failure instead,
// but that seems pretty annoying.

function foo(a match <pattern1>) {<body1>}
function foo(a match <pattern2>) {<body2>}
// does dispatch based on the first signature that passes all the argument matches
// under the covers, identical to:
// function foo_all(...args) {
//   return match(args) {
//     when([a and <pattern1>]): <body1>;
//     when([a and <pattern2>]): <body2>;
// }
//
// Alternately, if dispatch is too complicated for the committee,
// maybe it just gives the argument bindings `let x match` semantics,
// so failure to match will just give an undefined value.

Benefits:

  1. Useful functionality.
  2. Reuses the syntax of match(){}, thus reducing growth of the language overall.
  3. Familiar to users of Rust and some other languages.

Cons:

  1. Increases the size of the proposal quite a bit.

I agree with Jordan that we should have these listed as likely follow-ons, but not have them as part of the initial proposal. (It's big enough as it is.) So long as we make sure our pattern syntax remains compatible with doing this (which it is today, and I have no reason to expect it'll change), we're golden.

Idents as Custom Matchers Rather Than Irrefutable Matchers

This is the big thing that's bugged Yulia, I think, and ties into the previous two. If bindings don't escape the alternate forms (above) by default, then the "idents are irrefutable matchers that always succeed and introduce a binding" behavior is worthless, and forces us to use interpolation syntax even for simple cases.

Basically she'd like to be able to write let x match Number = y; and have it act the same as let x match ${Number} = y, invoking the Number[Symbol.matcher].

I see two ways to go about this. First is to have a keyword that context-switches us into "idents are custom matchers" mode at the current pattern level. For example, when(match Foo and Bar) would be equivalent to when(${Foo} and ${Bar}). Nested patterns would reset to "idents are irrefutable matchers" behavior, so when(match Foo and [a, match Bar]) would invoke Foo on the matchable, verify the matchable is a 2-element array, bind the 0th item to a, and then invoke Bar on the 1st item. (Joining patterns with and/or would carry the mode-switch down; it would reset at object/array patterns and in ${...} with x.)

I think this looks pretty reasonable on its own, and if we specifically use the same keyword as we use to introduce matchers in other contexts (match, here), then we can say that those other contexts get that behavior at the top-level automatically, so let x match Foo and Bar = y; works as Yulia desires.

Second possibility is to change the current proposal so that bare idents are always invoked as custom matchers (that is, when(Foo) would be identical to when(${Foo})), and then have a new matcher syntax for irrefutable matchers. Suggested pattern is as x, like {key: as x} or [as a, as b], etc. Combining with actual tests is same as today: [as a and Foo] both invokes Foo on the 0th item and binds it to a.

(This is very similar but not exactly equivalent to the use of as in import statements to do this kind of renaming/binding. There they use {a as b} directly. However, import statements don't then allow further destructuring of a value after a rename, nor do they allow arrays, so they're simply not trying to solve the same problems we are.

Alternate syntax could be to reuse let/const/maybe var, so you'd write {key: let x and Foo} to bind the .key value to x and invoke Foo on it for matching. However, this might cause confusion in arrays: [let a, Foo] looks like let a, Foo which is two bindings, rather than a binding of the 0th item and a test of the 1st item.

@rbuckton
Copy link

rbuckton commented Sep 3, 2022

I need to spend quite a bit more time on this, but one small thing that occurred to me regarding "plain functions as custom matchers". What if, instead of adding [Symbol.matcher] to Function.prototype or introducing new match syntax, we modified ClassDefinitionEvaluation to add a default [Symbol.matcher] to any class?

I also strongly favor reducing dependence on interpolation (an opinion I am writing up in a separate document I hope to publish next week), such that bare identifiers (and potentially qualified names like Foo.Bar) can be looked up from the surrounding scope. Then we could potentially have bare identifiers favor Symbol.matcher followed by instanceof, while interpolation patterns could favor Symbol.matcher followed by unary function evaluation.

We could also consider introducing a modifier to function or arrow functions a la async, i.e.:

match function isOk(value) { ... }

const isOk = match (value) => ...;

// desugaring
function isOk(value) { ... }
isOk[Symbol.matcher] = function (value) { return this(value); };

I imagine it really depends on whether you prefer OOP or FP style. FP adherents would likely prefer function evaluation fallback, while OOP adherents would likely prefer instanceof fallback.

A final set of alternatives might be using class decorators or the introduction of function decorators (depending on which way the default behavior leans for custom matchers):

// if behavior is `Symbol.matcher`, then function evaluation:
@matchable
class Foo { } // adds a [Symbol.matcher] to class

// if behavior is `Symbol.matcher`, then instanceof:
const isOk1 = @matcher function(value) { ... };
const isOk2 = @matcher value => ...;

match (input) {
  when (${@matcher value => ...}): ...;
}

Given my preference for extractors, I do lean towards instanceof being the fallback behavior for the consistency here:

match (input) {
  // input instanceof Option.Some
  when (Option.Some(Map and let value)): ...; // value instanceof Map
  when (Option.Some(MyClass and let value)): ...; // value instanceof MyClass
  
  // input instanceof Option.None
  when (Option.None): ...
}

I also prefer the instanceof default if we were to introduce an infix expr is Pattern syntax, such that:

if (obj is Map) { ... }
if (obj is MyClass) { ... }
if (obj is Option.Some(MyClass)) { ... }

But such cases would also be addressed by having class add a default [Symbol.matcher]() implementation.

@rbuckton
Copy link

rbuckton commented Sep 3, 2022

Another option would be to support neither by default and require explicit [Symbol.matcher] in both cases, allowing us to ship a form of pattern matching and loosen the restriction in a follow-on proposal

@rbuckton
Copy link

rbuckton commented Sep 3, 2022

We could also consider a prefix operator like the original "pin" operator (^) as the marker for a function-based matcher, leaving instanceof as the default, i.e.:

match (col) {
  when Array: ...; // prefer `Symbol.matcher` (if an objet), then `instanceof` (if an object), then equality
  when Uint32Array: ...;
  when Set: ...;
  when Map: ...;
  when undefined: ...; // Same preference, just look up `undefined` and end up at using equality
  when NaN: ...; // Same preference, just customize for `isNaN` when a number.

  when ${getSomeClassConstructor()}: ...; // interpolation could follow default preference above.
}

match (response) {
  when ^isOk: ...; // prefer `Symbol.matcher`, then function call (if callable), otherwise equality
  when ^isRedirect: ...;
  when ^isNotFound: ...;
  when ^${getSomeMatcherFunction()}: ...; // the prefix `^` indicates the modified behavior using function call.
}

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