tc39 / proposal-pattern-matching

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

Change grammar to require using `let` or `const` for capturing values from within pattern. #297

Closed nmn closed 8 months ago

nmn commented 1 year ago

Motivation

When the patterns used within a match-when expression contain non-literal identifiers, the syntax can look ambiguous. The proposal chooses to always interpret things more like "destructuring" and creates a new binding rather than treat it like a reference.

Let me explain by using the first code sample in the README. Instead of looking at the just the match expression itself, consider its use within a function.

function lookFor(res, status, url) {
  return match (res) {
    // ...
    when ({ status, destination: url }) if (300 <= status && status < 400):
      handleRedirect(url)
    // ...
  }
}

Based on this proposal when ({ status, destination: url }) creates two new variables status and url which shadow the function arguments of the same name. This is both confusing and makes it harder to use variables in match expression patterns.

Current Solution

In order to get around the problem, the proposal chooses to use the ${} syntax to "interpolate" values into the match expression.

function lookFor(res, status, url) {
  return match (res) {
    // ...
    when ({ status: ${statue}, destination: ${url} }):
      handleRedirect(res.url)
    // ...
  }
}

This solution has a few problems:

  1. The match pattern is not a template string and so ${} doesn't make a lot of sense as an interpolation mechanic.
  2. It makes uses existing variable more difficult than it should be.
  3. It doesn't help make the expressions more readable overall.
  4. There is no way to check for the presence of a key without also creating a binding to that key in an object. { key } both checks that key exists and makes a binding key that can be used to read.

Proposed Solution

My proposal is to make creating new binding more explicit instead. Here's how the original example would be written instead:

function lookFor(res, status, url) {
  return match (res) {
    // ...
    when ({ status: let status, destination: const url }) if (300 <= status && status < 400):
      handleRedirect(url)
    // ...
  }
}

The short point of this proposal is that whenever a part of the pattern is to be captured as a variable or constant binding, a let or const must be used explicitly to do so.

Here are all the examples re-written with this in mind:

match (res) {
  when ({ status: 200, body: const body, ...const rest }): handleData(body, rest)
  when ({ status: const status, destination: let url }) if (300 <= status && status < 400):
    handleRedirect(url)
  when ({ status: 500 }) if (!this.hasRetried): do {
    retry(req);
    this.hasRetried = true;
  }
  default: throwSomething();
}

and and or expressions.

match (command) {
  when ([ 'go', let dir = ('north' or 'east' or 'south' or 'west')]): go(dir);
  when ([ 'take', let item = /[a-z]+ ball/ and { weight }]): take(item);
  default: lookAround()
}

I'm not particularly fond of introducing new syntax for and and or in Javascript as it doesn't follow any of the syntactic rules already in Javascript. Instead, I would propose the following:

match (command) {
  when ([ 'go', let dir]) if (['north', 'east', 'south', 'west'].includes(dir)): go(dir);
  when ([ 'take', let item]) if (/[a-z]+ ball/.test(item) && item is { weight }): take(item);
  // using the `is` expression above.
  default: lookAround()
}

Next let's talk about Array length checking. I disagree that [page] should only match an array with a single element by default.

To be consistent with Javascript as a whole, we should match as long as the elements in the pattern exists. Instead, we should be looking for undefined which is what you get when you try to read an element of an array that doesn't exist.

match (res) {
  when ({ date: [undefined] }): ...
  when ({ data: [let page, undefined] }): ...
  when ({ data: [let frontPage, ...let pages] }): ...
  default: { ... }
}

This pattern, assumes that you wouldn't explicitly add undefined values within your Array, but that should usually be the case anyway. (We can also use void instead of undefined if that makes this pattern more viable:

match (res) {
  when ({ date: [void] }): ...
  when ({ data: [let page, void] }): ...
  when ({ data: [let frontPage, ...let pages] }): ...
  default: { ... }
}

If not, we can do this:

match (res) {
  when ({ date: let arr }) if (arr.length === 0): ...
  when ({ data: [let page, ...arr] }) if (arr.length === 0): ...
  when ({ data: [let frontPage, ...let pages] }): ...
  default: { ... }
}

The syntax change should apply to with as well:

match (arithmeticStr) { when (/(?\d+) + (?\d+)/): process(left, right); when (/(\d+) * (\d+)/ with [let _, let left, let right]): process(left, right); default: ... }

Although this entire example feels like something that extends the JS syntax too much IMO.


Interpolations wouldn't need to exist:

const LF = 0x0a;
const CR = 0x0d;

match (nextChar()) {
  when (LF): ...
  when (CR): ...
  default: ...
}

Custom matcher protocol interpolations wouldn't be needed. Instead this is how we could do the Option type instead:

class Option {
  constructor (hasValue, value) {
    if (hasValue) {
      return new Option.Some(value);
    }
    return new Option.None();
  }
  static Some = class Some {
    #value;
    constructor (value) {
        this.#value = value;
    }
    get value() {
      return this.#value;
    }
  }
  static None = class None {
    get value() {
      throw new Exception('Can’t get the value of an Option.None.');
    }
  }
}

match (result) {
  when (instanceof Option.Some) is ({ value: let val }): console.log(val);
  when (instanceof Option.None): console.log("none");
}

At a risk of putting too much in this issue, I propose the following syntax for matching objects that are instances of particular classes: <ClassName> { <...keys to match...> }

This is already how various testing tools print objects that instances of classes.

match (result) {
  when (Option.Some { value: let val }): console.log(val);
  when (Option.None {}): console.log("none");
}

This would also make the built-in matchers much simpler:

match (value) {
  when (Number {}): ...
  when (BigInt {}): ...
  when (String {}): ...
  when (Array {}): ...
  default: ...
}

BUT: 1 instanceof Number isn't true in Javascript and so I don't think the above should actually work. (This makes me sad, but I believe being consistent is more important than adding the convenience of making the aboce work). Instead we would need some kind of pattern for matching of typeof. Without introducing any new syntax the above would need to be written as:

match (value) {
  when (let num) if (typeof num === 'number'): ...
  when (let bigNum) if (typeof bigNum === 'bigint'): ...
  when (let str) if (typeof str === 'string'): ...
  // This already works:
  when (Array {}): ...
  // But we would probably write `[]` anyway:
  when ([]): ...
  default: ...
}

Matching Fetch Responses:

const res = await fetch(jsonService)
match (res) {
  when ({ status: 200, headers: { 'Content-Length': let s } }):
    console.log(`size is ${s}`);
  when ({ status: 404 }):
    console.log('JSON not found');
  when ({ status: let status }) if (status >= 400): do {
    throw new RequestError(res);
  }
};

Redux Reducer Example:

function todosReducer(state = initialState, action) {
  return match (action) {
    when ({ type: 'set-visibility-filter', payload: let visFilter }):
      { ...state, visFilter }
    when ({ type: 'add-todo', payload: let text }):
      { ...state, todos: [...state.todos, { text, completed: false }] }
    when ({ type: 'toggle-todo', payload: let index }): do {
      const newTodos = state.todos.map((todo, i) => {
        if (i !== index) { return todo; }
        return match (todo) {
          when ({completed: true}): {...todo, completed: false};
          when ({completed: false}): {...todo, completed: true};
        }
      });

      ({
        ...state,
        todos: newTodos,
      });
    }
    default: state // ignore unknown actions
  }
}

JSX example:

<Fetch url={API_URL}>
  {props => match (props) {
    when ({ loading }): <Loading />
    when ({ error: let err }): do {
      console.err("something bad happened");
      <Error error={err} />
    }
    when ({ data: let data }): <Page data={data} />
  }}
</Fetch>

Advantages of this proposal

Here are some reasons I think the proposal above improves the match expression:

  1. It is more readable at a glance to know when new variables are being created.
  2. It is easy to decide whether the bindings should constants or not.
    • And it is possible to mix variables and constants in the same expression.
  3. There's less new Syntax needed overall. For the most part, the match expressions should be small superset of what can be passed into the .equals() method of most test runners. Simple object and array equality where the rules mostly match those of Destructuring and Spreading is what we should aim to do with this propoal.
  4. It is easy to explain what various patterns de-sugar to. (I'll try to write down examples in a comment)

Further Possible Enhancements

Restating Syntax for instanceof checks

In the general case, we should be able to match any arbitrary Object by its keys and values:

// This matches any object that contains the key `name`
when ({ name }): 

// This matches any object that contains the key `name` pointing to the value: "Joe"
when ({ name: "Joe" }): 

// This matches any object that contains the key `name` which is equal to `someName`
when ({ name: someName }): 

However, when we want to match objects that are instances of classes, things can quickly become wordy. So I proposed the following syntax:

match (creature) {
  when (Person { name: let name, age: let age }): // ...
  when (let animal = Animal { }): match (animal) {
    when (Dog { name: let name, breed: let breed }): // ...
    when (Cat { name: let name, coatColor: let color }): // ...
    // ...
  }
}

Using the Class { ...keys... } syntax is already familiar to Javascript developers and I don't think it complicates anything.

Punning Key Names

You may have noticed that when capturing the value of keys I wrote { name: let name } in my examples. I think that the initial version of this API should enforce this pattern and disallow any object key punning. The pattern { name } should simply check for the existence of the name key and nothing else.

As a possible enhancement, we could allow both checking for the existence of key and capturing its value without repeating it: { let name }

match (creature) {
  when (Person { let name, let age }): // ...
  when (let animal = Animal { }): match (animal) {
    when (Dog { let name, let breed }): // ...
    when (Cat { let name, coatColor: let color }): // ...
    // ...
  }
}

This still keeps the patterns reasonably readable IMO, so it's worth considering if this keeps a good balance between readability and conciseness.

An extension to the Switch statement

I like that match is an expression. But for completeness we should add a small extension to the Switch statement as well.

switch (creature) {
  when (Person { let name, let age }): // ...
  when (let animal = Animal { }): match (animal) {
    when (Dog { let name, let breed }): // ...
    when (Cat { let name, coatColor: let color }): // ...
    // ...
  }
}

This would world exactly like the match expression except for the fact that it is a statement instead.

Depending on the various tradeoffs when it comes to adding new syntax, it might even make sense to reuse the case keyword along with the is keyword which is also part of this larger proposal:

switch (creature) {
  case is (Person { let name, let age }): // ...
  case is (let animal = Animal { }): // ...
}

Prior Art

The Swift Language uses similar patterns in it's own switch statement.

nmn commented 1 year ago

Further explaining my proposal by explaining what the examples would desugar to:

Example

function lookFor(res, status, url) {
  return match (res) {
    // ...
    when ({ status: let status, destination: const url }) if (300 <= status && status < 400):
      handleRedirect(url)
    // ...
  }
}

De-sugars to:

function lookFor(res, status, url) {
  let _status, _url;
  if (Object.hasOwn(res, 'status') 
  && (_status = res.status, true)
  && Object.hasOwn(res, 'destination')
  && (_url = destination, true)
  && 300 <= status
  && status < 400) {
    let status = _status, url = _url;
    return handleRedirect(url)
  }
}
match (creature) {
  when (Person { let name, let age }): // ...a
  when (let animal = Animal { }): match (animal) {
    when (Dog { let name, let breed }): // ...dog-stuff
    when (Cat { let name, coatColor: let color }): // ...cat-stuff
    // ...
  }
}

De-sugars to:

do {
  if (creature instanceof Person
  && Object.hasOwn(creature, 'name')
  && Object.hasOwn(creature, 'age')) {
    let name = creature.name, 
        age = creature.age;
    // ...a
  } else if (creature instanceof Animal) {
    let animal = creature;
    do {
      if (animal instanceof Dog
      && Object.hasOwn(animal, 'name')
      && Object.hasOwn(animal, 'breed')) {
        let name = animal.name, breed = animal.breed;
        // ...dog-stuff
      } else if (animal instanceof Cat
      && Object.hasOwn(animal, 'name')
      && Object.hasOwn(animal, 'coatColor')) {
        let name = animal.name, color = animal.coatColor;
        // ...cat-stuff
      }
    }
  }
}
Jack-Works commented 1 year ago

I believe most of your concerns are considered in #293. please take a look thanks!

tabatkins commented 1 year ago

Yeah, I'm so sorry that you went to all this trouble to write out this big proposal, when we'd already decided to do more or less exactly this in #293. ;_; It'll be committed into the repo in a little while, when we finish up the edits.

nmn commented 1 year ago

I know! Our proposals are almost the same.

I want to callout one possible enhancement that I proposed here:

The matcher pattern for objects that are instances of a class:

Person { name } - Would work exactly the same as { name } but it would also check that the value being matches is an instance of Person.

tabatkins commented 1 year ago

Ah, that's already handled by the existing patterns - Person and {name} just runs both patterns, one invoking the Person custom matcher (defaulting to an instance check) and the other checking the properties.

nmn commented 1 year ago

Didn't think of that. Good to know.

By the way, won't using "and" and "or" be problematic? Do we need to use symbols for unambiguous parsing?

ljharb commented 1 year ago

Since they’re surrounded by spaces i don’t anticipate any issues.