tc39 / proposal-pattern-matching

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

Should literals also go through Symbol.matches? Plus, general special-casing considerations #6

Closed domenic closed 6 years ago

domenic commented 7 years ago

It seems at least doable that numeric, string, and boolean literals (see #3 for the latter) could go through Number.prototype[Symbol.matches].

I'm not sure whether this buys you much though, given that you already have special-cases for the object and array patterns.

I guess maybe it would allow you to simplify the syntax to:

where in the third case you go the Symbol.matches route. This third case would subsume the current identifier and literal cases.

Curious what people think.

bterlson commented 7 years ago

You could even conceivably get rid of the special case matching for objects and arrays as long as you had a sentinel value for any value:

match (p) {
  { x: anything, y: anything }: ...
}

which would simply make a new object with x and y properties of the particular value, and O.p[Symbol.matches] does a property-by-property comparison.

Also curious what others think.

tabatkins commented 7 years ago

I like the "any value" sentinel, as long as it's short and built-in. _ would be ideal, if it wasn't already used by things. I guess we'd have to go outside of ident syntax, like a bare # or something?

tabatkins commented 7 years ago

Ah, but you can't get rid of the special-casing for ... in objects and arrays, unless you add that as a special object-creating syntax as well. (Like Python has the Ellipsis object.) I'm okay with that, fwiw.

A nice property of this is that you can reuse the array/object matching syntax with other types of specialty objects, just passing the "pattern" array/object as an argument to a function, like mySpecialArraylike([foo, ...]) - with mySpecialArraylike() doing some processing on its argument and returning an object with a custom Symbol.matches property.

Jamesernator commented 7 years ago

Yeah I'd rather see pattern matching be expression : expression.

I actually wrote a library that I never formally released that works very similarly to the proposed Symbol.match proposed here.

What I learnt from it is that it's very nice to be able to nest patterns inside other patterns when validating complex structures, e.g. I was using pattern matching to validate a js AST I was generating and had rules like this:

import Expression from "./Expression.js"

class ArrayExpression {
    static [matchSym](value, matches) {
        const pattern = {
            type: 'ArrayExpression',
            elements: arrayOf(or(Expression, null))
        }
        return matches(pattern, value)
    }
}

// true assuming expr1, expr2 both match Expression
match(ArrayExpression, {
    type: 'ArrayExpression',
    elements: [expr1, expr2, null, null]
})

I don't really see a way of doing complex stuff like that using the array/object special syntax given that it seems to only do length checking in the case of Array patterns and key existence in the case of Object patterns, I'd prefer something considerably more powerful by default.

If we were to go with the form of expression : expression though I think it'd be useful to have a way to destructure in there as well e.g.:

function sum(array) {
    return match (array) {
        []: 0,
        [number]: [x] -> x,
        [number, ...]: [x, ...rest] -> x + sum(rest),
        // Assuming we have some way to throw in a match clause
        else: throw new Error(`Can't sum non-number`)
    }
}

Which I think would be reasonably powerful and elegant.

tabatkins commented 7 years ago

Oooh, separating out the matching from the destructing/binding actually solves a lot of issues, at the cost of making it a little more verbose in some common cases.

  1. It avoids the need for a foo@pattern syntax a la Haskell, when you want to both bind something by name and match it against something (currently only possibly in the object syntax).
  2. It lets you bind an extracted property to a name different than what it currently has (destructuring uses the nesting to accomplish that; patterns use nesting for a different thing).
  3. It avoids the confusion in general about an ident indicating a binding vs a matching (Foo looks for Foo[Symbol.matches], but [Foo] binds the first element of a length-1 array to the name Foo).
  4. It lets us use the full power of destructuring, and benefit from any future enhancements to destructuring, without worrying about it colliding with matcher syntax.

The only downside is a bit of repetition - if you just want to see if an array is length-1, and grab that element if so, you need to write [anyvalue]: [x] -> ...,; similarly for an object where you just want to grab a particular property: {x: anyvalue}: {x} -> ...,. It's not a huge deal, but it does make a smallish usability difference.

As your example notes, the only thing required to make that work then is the addition of ... actually creating some Ellipsis-like object, so you can use it in array matches.

zkat commented 7 years ago
I don't think any pattern-matching system for JS should have this sort of type syntax built-in. I think this sort of thing is much better off getting modeled after Erlang/Elixir, rather than a statically-typed language where types are more relevant, more often. I'd like to strongly advocate for leaning on guards for non-structural match filtering. This strikes me as far more JavaScripty than having `foo: type` spread around all over the place, : ``` match (p) { x when isNaN(x) -> console.log('nanananananananananananananananaBATMAN') x -> console.log('idek') } ``` I think this has the added advantage over the example in https://github.com/tc39/proposal-pattern-matching/issues/6#issuecomment-312840858 that it's not vulnerable to tiny programmer errors where someone fails to maintain congruence between the type and the variable sides. I also think `typeof foo === 'x'` is not an expression worth optimizing this much in syntax. JS is much more comfortable being YOLO af about this stuff. `typeof` is rare enough, ime, and `typeof foo === 'undefined'` is probably way more common than any other "type" check. It just doesn't deserve to have syntax reserved for it like this. `when typeof x === 'foo'` is good enough here, imo, for how often I think it'd get used in practice.

edit: I came into this thread from a discussion elsewhere and I lost the context while mixing the two conversations together. I don't think my comment here is directly related to the thread here so I've moved it behind a <details>.

domenic commented 7 years ago

@zkat I'm confused what you're responding to. I don't think anyone in this thread has proposed typeof-based matching, nor does the proposal in the README...

tabatkins commented 7 years ago

From the proposal:

A basic implementation might simply be return value instanceof this.constructor. Such a basic implementation could be created by default for types created via class keyword.

domenic commented 7 years ago

Sure, but that's not typeof, and is also unrelated to the OP... if people want to discuss that, I'd suggest a different issue?

zkat commented 7 years ago

(I've edited my comment)

Jamesernator commented 7 years ago

I wonder if you could have both the separate and combined syntax:

match (something) {
    // No binding at all
    []: 0,
    // Seperate binding to matching
    [any]: [x] -> x.toString(),
    // Combined binding and matching equivalent to previous
    [x] :-> x.toString(),
    // Works fine with objects too
    { x: any } : { x } -> x.toString(),
    { x } :-> x.toString(),

    // Would need some form of additional syntax 
    // to further match inner types though
    [x@number] :-> x.toString(),
    { x@number : y } :-> y.toString(),
}

Also regarding ellipsis as a value, I think it'd be nice to be able to pass types into the ellipsis e.g.:

match (dataLine) {
    ['open', ...string /* 0 or more strings */] : ...,
}

Perhaps a slightly different token e.g.:

match (dataLine) {
    // Probably confusing
    ['open', ..string] : 1,
    // Also probably confusing
    ['open', string...] : 2
}

Or alternatively an addition to the ArrayExpression/ObjectExpression grammars:

// & Makes it very clear its separate syntax from spread
const pattern1 = ['open', &...string]

// Could just add a Symbol field for rest:
pattern1[Symbol.rest] === string // true

// Also works with objects nicely:
const pattern2 = { x: 10, y: 20, &...empty }
pattern2[Symbol.rest] === empty // true

// Might take more work to support left/inner rest though
const pattern3 = [&...number, Function]
pattern3[Symbol.restIndex] === 0 // true
    // or
pattern3[Symbol.rest].index === 0 // true
pattern3[Symbol.rest].value === number // true

const pattern4 = [number, &...string, number]
pattern3[Symbol.restIndex] === 1 // true
zkat commented 6 years ago

Hey y'all! #65 has gotten merged, and a lot of issues have become irrelevant or significantly changed in context. Because of the magnitude of changes and subtle differences in things that seem similar, we've decided to just nuke all existing issues so we can start fresh. Thank you so much for the contributions and discussions and feel free to create new issues if something seems to still be relevant, and link to the original, related issue so we can have a paper trail (but have the benefit of that clean slate anyway).

The new extractor-based API in this proposal should clarify this particular usecase pretty well.