Proposal-pattern-matching: Allow pattern matcher produce result?

Created on 11 May 2020  ·  28Comments  ·  Source: tc39/proposal-pattern-matching

As the requirement from https://github.com/tc39/proposal-object-rest-spread/issues/66#issue-421834424 , it would be helpful if pattern matcher could produce result so we can write:

case (['1', '2']) {
  when [Numeric n, Numeric m] -> assert (n === 1 && m === 2)
}

Note the syntax could have many options, for example Numeric(n) as #117 , though I think Pattern binding syntax is enough and much simpler, it could just use the return value of Numeric[Symbol.case](input)

It would even much useful combined with other ideas, for example when `id-${id}` -> as https://github.com/tc39/proposal-pattern-matching/issues/151#issuecomment-623788348 could be

when `id-${Numeric id}` -> assert (typeof id === 'number')

Most helpful comment

@Jack-Works Personally, I'm not a fan of that idea. Destructuring of arrays is overloadable, and given the syntactic similarities between matching and destructuring of arrays, I strongly believe that the two should have the same semantics.

More generally I don't agree that operator overloading (which is essentially what we're talking about) is naturally "bad" or "damaging". Yes, it can be abused, but it also opens up a lot of flexibility. It has precedent in many languages (including JS, _right now_), and (based on recent meeting notes) seems to be something TC39 is looking more into.

All 28 comments

I'm very confused by this - what do you mean "produce result"? If you mean, the match construct should be an expression, the proposal already does that (and will hopefully continue to).

From reading the linked issue, that seems like something that would need to be its own proposal, and would need to apply to more things than just pattern matching - and would be inappropriate to add just in this proposal.

@ljharb As I understand, currently pattern match only check whether a input match the pattern, and if match, it will create a binding for the right side of ->. Normally the value of the binding is same as the input (or correspond the parts in iterable pattern like [a, b] or object pattern like {a, b}.

What I suggest here are two things:

  • First, a customizable pattern. (Numeric in the example)
  • Second, the matcher of Numeric could return value and the binding would be that value

Basically, the code

case (value) {
  when Numeric n -> console.log(n)
}

could roughly mean:

let result = Numeric[Symbol.case](value)
if (result.matched) {
  let n = result.value
  console.log(n)
}

Note there may be many syntax/semantic details, but the idea is just like that.

Would that be the only matching mechanism, or would that be in addition to all the built in mechanisms the proposal currently supports?

I think it's possible to make it a "only matching mechanism", because literal patterns can be achieved by :

Number.prototype[Symbol.case] = function(value) {
  'use strict'
  if (value === this) return {matched:true, value}
}

case() {
  when 42 -> ...
  when 3721 n -> assert (n === 3721)
}

Iterable pattern also can be achieved by :

Array.prototype[Symbol.case] = function (input) {
  let i = 0, value = []
  for (const x of input) {
     if (i >= this.length) return  {matched: false}
     const r = this[i][Symbol.case](x)
     if (!r.matched) return {matched: false}
     value.push(r.value)
     ++i
  }
  if (i !== this.length) return {matched: false}
  return {matched: true, value}
}

Of coz, we still need special treatment for bindings in iterable pattern. And we may have trouble of [a, b, ...] which means could match 2+ size iterables. (may be make ... a symbol could solve that --- crazy idea!)

The main problem may be object pattern, whether having an Object.prototype[Symbol.case] a good idea? Maybe not, because it means if there is anything u don't want it be matchable, u need to manually overrride it to throw in your class.(deleted because I now realized u always need to manually override it if u don't want object pattern apply to your object )

I think it's ok that literal/iterable/object pattern just use some internal magic without [Symbol.case]() in their prototype, but allow programmers custom it, just like Symbol.hasInstance. I feel making literal/iterable/object pattern could be explained (or even overrided if user really want) by [Symbol.case]() will help programmers get the uniform mental model about pattern match.

PS. consider iterable/object patterns with bindings are really magic anyway, I am also ok to don't make them relate to Array/Object.prototype[Symbol.case] :-)

My main concern with something like this is that the pattern matcher effectively must be a chain of ifs and cannot be compiled to a switch. So if you have a case with 100+ patterns you have to call 100 Symbol.case functions to match the last pattern. I don't think developers will expect that since that's not how it works with other pattern matching systems.

In addition, null objects then wouldn't be matchable. Object destructuring patterns, however, would work for them.

So if you have a case with 100+ patterns you have to call 100 Symbol.case functions to match the last pattern.

I think, if u have 100 patterns, for any input, u always need to match 100 times to match the last pattern. For custom patterns, u always need call Symbol.case or similar things. So the difference may be, whether the check of existence of Symbol.case on built-ins prototype for literal/iterable/object patterns. Not sure how big about the performance effect, but there are many similar things (most well-known symbols provide some custom/hook point which need to be check in runtime) in the language, I guess engines already have some optimization for it (check whether there is any well-known symbols added to builtins prototype, if not, use fast path)

I don't think developers will expect that since that's not how it works with other pattern matching systems.

Maybe. But as we know, it's the js convention of using well-known symbols to provide the custom point and also allow customize the builtins (though in practice I believe we don't encourage people override the builtin's well known symbols).

In addition, null objects then wouldn't be matchable.

@ljharb

What do u mean about "null objects" ? Do u mean how to match null/undefined values? If that, they could be matched like this:

value = v => ({
  [Symbol.case](input) {
    return { matched: Object.is(input, v) }
  }
})

when value(null) -> ...

This also work for other special values like NaN.

Of coz, we could allow some literal auto generate a magic pattern. We can discuss the pros/cons of it in a separate issue.

A "null object" is one where its [[Prototype]] is null, like Object.create(null).

Object.create(null)

@ljharb
If u want to match a specific null prototype object (input === nullObj), u could also use when value(nullObj) -> ... using value in my previous example.

But I guess u mean match all null prototype objects?

Note when { __proto__: null } -> ... won't work in current proposal (if i understand correct). Maybe when {__proto__: undefined} could work? But even it work, it looks weird 😂 and not accurate , I'd rather use when x if (Object.getPrototypeOf(x) === null) which is the clear and the accurate way (because null prototype objects can have non-null __proto__ own property theoretically ).

If we follow current proposal, the other option might be add some magic rule for {__proto__} pattern to make it work as programmers expectation. It's ok because the pattern in current proposal already is new magic thing, and {__proto__:v} literal is also magic enough, so add new magic won't make things any worse I guess. 😂


Now, let's discuss my [Symbol.case]()-based solution. In such solution, pattern is just a expression, and using the [Symbol.case]() method of the result value of the expression. Though it still have some (opt-in) magic of binding syntax, I guess it would look less magical than current proposal for average programmers.

There are three options as we discussed in previous comments. Option A is let Symbol.case "be the only matching mechanism". Option B is use Symbol.case if available and fallback to some default behavior if not available. Option C is do magic for [], {} (maybe also literal) patterns so essentially they are not expressions any more, just look like expressions, use Symbol.case for other expressions. I'll discuss them one by one for matching null prototype objects case.

Option A

In some degree, this is actually the simplest one in concept model perspective. Patterns are just objects with [Symbol.case](input) method, if no such method, it just throw TypeError.

So u can't use {__proto__: null} to match null prototype objects, if u use it, TypeError will be thrown and the message may tell u it's not a valid pattern object.

To match null prototype objects, u could use guard when {} x if (Object.getPrototypeOf(x) === null), or the better way is write your own pattern object like:

const NullObject = {
  [Symbol.case](input) {
    if (Object.getPrototypeOf(input) === null) return {matched:true, value:input}
  }
}

when NullObject x -> ...

Option B

If no Symbol.case, we fallback to a default behavior, aka. the default [Symbol.case]() implementation. Basically Option A is just a special version of B: throw TypeError by default.

A possible default version

DEFAULT = {
  [Symbol.case](input) {
    const pattern = this
    // check for builtin types so
    // we don't need add `[Symbol.case]()` on builtin's prototype
    if (!IsObject(pattern)) { // primitive value
       return {matched: pattern === input, value: pattern}
    }
    if (IsArray(pattern)) {
      // do something like Array.prototype[Symbol.case]() in my previous comment
    } 
    if (IsRegExp(pattern)) {
       // possible do some interesting thing for regexp to support
       // when /^id-(\d+)$/ m -> parseInt(m[1])
       const x = pattern.exec(input)
       if (!x) return {matched: false}
       else return {matched: true, value: x}
    }
    ...
    // for normal object
    for (const [key, subpattern] of Reflect.ownKeys(pattern)) {
       // check whether input[key] match subpattern (recusively) 
       // code omitted
    }
  }
}

And as this version, {__proto__: null} won't have any effect (just be ignored because it does not add any subpattern). Personally I think it's acceptable and easy to explain.

Instead, u could use guard for null prototype object test, or NullObject custom pattern. Ideally, I hope we could use two (or more) pattern together:

when NullObject & {x: 1, y} -> ...  // overload & may be problematical, just for demo

Of coz we could make {__proto__: null} work if we really want:

    // for normal object
    if (Object.getPrototypeOf(pattern) === null && 
      Object.getPrototypeOf(input) !== null) return {matched: false}
    for (const [key, subpattern] of Reflect.ownKeys(pattern)) {
    ...

Option C

We are free to add any magic we want.

The only problem may be, when [0] -> ... and when ([0]) -> ... will have different result if someone add [Symbol.case] to Array prototype, seems not a big problem in practice.

@kmiller68 It's hard for me to imagine that JS pattern matching would compile into anything but a chain of ifs in the general case. Otherwise, the expressiveness would be too limited to be very useful.

@hax im saying i want to treat null and normal objects the same, and match on their properties irrespective of their [[Prototype]].

@ljharb The way I read this is that Symbol.case is to be found from the value in when clause match pattern. The [[Prototype]] of the value in case (expression) seems irrelevant.

case (Object.create(null, {bar: {value: 1}})) {
  when { foo } ->
}

This would either call Object.prototype[Symbol.case] (based on the prototype of { foo }) or be special cased to work without calling the Symbol.case implementation like @hax suggested in a comment here.

Thanks, that clarifies things a bit, altho I don't think it makes sense for destructuring syntax - a robust thing - to make an observable runtime call to a replaceable function.

@ljharb If it's any consolation, there are currently multiple instances of JavaScript syntax making runtime calls into replaceable functions:

  • const [ ... ] = ... calls into the [Symbol.iterator] method on the given iteratee
  • for ... of calls into the [Symbol.iterator] method on the given iteratee
  • for await ... of calls into the [Symbol.asyncIterator] method on the given iteratee
  • instanceof calls into the [Symbol.hasInstance] static method on the given constructor
  • ...Etc.

I'd argue that there's a pretty well established precedent set that would make this acceptable (especially given that first example).

@treybrisbane yes, and that's why i'm very strongly opposed to adding more if we can avoid it. instanceof was unreliable long before ES6, and node core is already going to have to avoid using array destructuring syntax, or any for..of loops, if it wants to be robust against userland damage.

@ljharb Just because a tool can be misused doesn't necessarily invalidate the tool itself... I guess it depends on our "threat" model... But I think that's very much a separate discussion. 😄

For the record, I actually don't support this particular proposal, as I personally don't believe that the presented use-case should be solved via a pattern matching construct. I'm just pointing out that, for better or worse, there is well established precedent for what is effectively operator overloading. 🙂

I personally don't believe that the presented use-case should be solved via a pattern matching construct.

@treybrisbane If u look inside the semantic, actually it's very close to your #162 , just use different syntax and have the limitation of only support one binding.

is already going to have to avoid using array destructuring syntax, or any for..of loops, if it wants to be robust against userland damage

@ljharb So what if there is a syntax level difference to indicate where the match wants to use the @@case or not?

@Jack-Works that certainly might work

@hax While #162 could _enable_ the kind of transformations put forward in this issue, its _intent_ is very different. The intent of #162 is largely only to reproduce the set of values originally used to contruct some data. It's not intended to be used for non-trivial transformations of bound values, although it can theoretically be used for that.

@Jack-Works Personally, I'm not a fan of that idea. Destructuring of arrays is overloadable, and given the syntactic similarities between matching and destructuring of arrays, I strongly believe that the two should have the same semantics.

More generally I don't agree that operator overloading (which is essentially what we're talking about) is naturally "bad" or "damaging". Yes, it can be abused, but it also opens up a lot of flexibility. It has precedent in many languages (including JS, _right now_), and (based on recent meeting notes) seems to be something TC39 is looking more into.

although it can theoretically be used for that.

@treybrisbane If it could be used, it will be used :-P

@hax Sure, but my point is that such transformations aren't #162 's _primary purpose_. They can also be heavily discouraged by the community in the same way that other abuses of overloading are.

@treybrisbane Personally I think it's very hard to draw a line of the "purpose".

The example of Numeric could be code as your proposal like that:

// usage: when Numeric(v) -> v

const Numeric = {
  [Symbol.deconstructor](v) {
    const result = Number(v)
    if (!Number.isNaN(result)) return [result]
  }
}

If it was said a "abusing", programmers would rewrite it just to make it look close to primary purpose 😂:

class Numeric {
  constructor(value) {
    if (!Number.isFinite(value)) throw new TypeError()
    return String(value)
  }
  static [Symbol.deconstructor](value) {
    const result = Number(value)
    if (!Number.isNaN(result)) return [result]
  }
}

Is this issue essentially asking to implement F# active patterns?

It's infeasible without ES having tagged union types. If ES did have tagged unions and active patterns, the mapping/transformation of the values would follow immediately, since your transformation can be carried in the data of the tagged type instance returned by the active pattern.

But note that you don't need active patterns if you refactor your example to transform before matching.

const numeric = (values) => [values.map(Number)].map((nums) => nums.some(isNaN) ? undefined : nums)[0];

case (numeric(['1', '2'])) {
  [n, m] -> assert (n === 1 && m === 2)
}

And if more cases, you can simulate tagged unions however you wish that meets your pattern matching criteria.

The proposal has been updated in #174. The match construct is now an expression.

Was this page helpful?
0 / 5 - 0 ratings