Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Partial Map pattern matching #2861

Closed
jodinathan opened this issue Feb 23, 2023 · 22 comments · Fixed by #2960
Closed

Partial Map pattern matching #2861

jodinathan opened this issue Feb 23, 2023 · 22 comments · Fixed by #2960
Labels
patterns Issues related to pattern matching.

Comments

@jodinathan
Copy link

The code below prints NOT TOKEN.
It will print the token only if I change result case {'data': {'token': String token}} to result case {'data': {'token': String token, ...}}.
Can I check for the match without using the ... or 'expires': int _?
Sometimes the API you are fetching changes but not the part that you use. To prevent any exception I would have to add ... in any {} checks

void main() {
  final result = {"data":{"token":"foo", "expires":1677255660}};
  
  if (result case {'data': {'token': String token}}) {
    print('TOKEN $token');
  } else {
    print('NOT TOKEN');
  }
}
@mraleph mraleph transferred this issue from dart-lang/sdk Feb 24, 2023
@lrhn
Copy link
Member

lrhn commented Feb 24, 2023

No, you have to use ... or "expires": _, and only ... is future-proof if someone later adds more entries to the map.

A map pattern without a ... must match a map with the same number of entries as the pattern.

The only workaround I can think of is to introduce a horde of extension getters:

typedef JsonMap = Map<String, Object?>;
extension JsonGetters on JsonMap {
  Object? get token => this["token"];
  Object? get data => this["data"];
  Object? get expires => this["expires"];
}

and then do:

  if (result case JsonMap(data: JsonMap(: String token))) {
     print("TOKEN $token");
  }

That allows you to treat a string map as an object with getters, and therefore use an object-pattern, which does not expect to match length. It also doesn't check whether the map has that key at all, which is why the getter can return null.

You have to declare all the getter names you want to use.

(If we allowed arbitrary selectors left of the : in object patterns, we would be able to write JsonMap(["token"]: String token), but so far we don't allow that.)

@lrhn lrhn closed this as completed Feb 24, 2023
@jodinathan
Copy link
Author

can't we have a spread operator before the initial map so all maps are treated as partial?

ie

if (result case ...{'data': {'token': String token}}) {}

@munificent
Copy link
Member

We could do that, yes. In principle, we can do anything if it's not ambiguous and is feasible to implement. :)

But in practice, I suspect that that syntactic sugar wouldn't carry its weight. It's simpler and probably also clearer and better to just put ... inside each of the map patterns that you want to allow extra entries in.

@jodinathan
Copy link
Author

@munificent I am creating a package that is using map-matching extensively. I am getting a lot of ReachabilityError and end up thinking "forgot the spread... again".
I think it would be better if the map-matching was soft by default and stricter with some flag.

@munificent
Copy link
Member

I am creating a package that is using map-matching extensively.

That's definitely a valid use case, but it might not be the most common one.

I think it would be better if the map-matching was soft by default and stricter with some flag.

I could have sworn there was an issue where we discussed this choice but I dug around a bunch and can't find it. But the short summary is that we did discuss this and weighed the pros and cons as best as we could. There are some good arguments to make maps soft by default: It means it's a non-breaking change to add new unused map keys in JSON and other data structures that are being consumed by map patterns. However:

  1. It makes map patterns inconsistent with list and record patterns. It might be very surprising to a user if their map pattern silently ignored extra keys given that using list and record patterns has taught them that all elements must be matched. (On the other hand, object patterns aren't strict. But object patterns pretty clearly can't be strict: It would be insane to require users to match hashCode and toString on every object pattern!)

  2. If maps default to soft, then we need some syntax to opt in to strict, and I don't think anyone had any good ideas. Defaulting to strict and using ... for soft means that once you know ..., you know it for both kinds of patterns. And it's pretty easy to know that because other languages use similar syntax.

Given that, we felt it made the most sense to default to strict. It means the code is a little more verbose when you want soft matching, but it's hopefully easier to learn and less error-prone.

@jodinathan
Copy link
Author

jodinathan commented Mar 7, 2023

@munificent imagine that you have a worker that sends email through a service and that service is well known, thus the API shouldn't change without notice.
However, the service is testing some internal changes and are adding some simple log id to the resulting json, ie "_": 1234567... Boom, caos happen

Relying on JSON structure is asking for trouble.

Having this in mind I would ask to have a lint that warns the dev to always add the ... to map checking to make sure no sudden changes would harm us.

If maps default to soft, then we need some syntax to opt in to strict, and I don't think anyone had any good ideas.

if (map case {'foo': int foo}!) {}

if (map case! {'foo': int foo}) {}

@lrhn
Copy link
Member

lrhn commented Mar 7, 2023

if (map case {'foo': int foo}!) {}

Already means "throw if null".

if (map case! {'foo': int foo}) {}

Should be on the map pattern itself, not the case, so more like:

if (map case !{'foo': int foo}) {}

Possible, but not particularly readable.

I'm personally fine with requiring ... if you want to allow more entries.

The one issue is that the error case happens dynamically. If all your tests happen to pass a map with just the expected keys, nothing prompts you to add the ... just to be future proof. It's easy to forget.

But if the default was the other way around, it would be equally easy to forget the ! to prevent further keys, if you want to. Less breaking, as you will still keep trucking, and ignoring the extra entries.

If you put ... on every map pattern where you don't control the input, then you're safe. More writing, but also very explicit. A case {"x": var x, "y": var y} matches a map with precisely that shape, {"x": var x, "y': var y, ...} very clearly accepts more entries. Just like list patterns.
I think the consistency and explicitness is worth it.

@jodinathan
Copy link
Author

I understand your point. You want to make map-matching like Record-matching. Like if a Map is data for some immutable class or something like that. I get it.

My point is that no one expects a Map to be solid. They are unstable by nature. Even when you are the one creating the Map. This is because devs are used to put anything within maps just to be able to get information at runtime.
Records on another hand are bare iron structures that you expect to not change at all.

if (map case {'foo': int foo}!) {}
Already means "throw if null".

If I understood it correctly it would be the same as final {'foo': int foo} = map, right?
If so, then I would definitely change from throw if null to use it as flag to stricter checking.

Please, give a thought about it.

@munificent
Copy link
Member

Having this in mind I would ask to have a lint that warns the dev to always add the ... to map checking to make sure no sudden changes would harm us.

It's probably going to be a best practice to use ... for maps whose creation you don't control. But there are also maps where you know it won't have extra keys, so I'm not sure that a global lint is the right answer.

I agree that this is a worrisome corner of the proposal. Like @lrhn notes, we can't use a postfix ! because that already means something. (And, even if we could, it's not clear that that's good syntax for this since ! means other things elsewhere in the language.)

Syntax design is hard. Picking defaults is harder. Picking defaults for what syntax means is doubly hard.

If we really think that almost every map pattern should be non-strict, maybe the right answer is to make that the default. And if you really do want a strict map, do:

switch (map) {
  case {'key': 1, 'other': 2} && Map(length: 2): ...
}

But that's really ugly if it turns out that users will often want strict matching. :-/

@jodinathan
Copy link
Author

I agree that this is a worrisome corner of the proposal

the point @munificent is that Maps are dynamic structures by nature.
Whenever I remember starting a project that uses Maps or JSON, I do remember that they never end as they started, meaning their structures always change in the development process.

it's not clear that that's good syntax for this since ! means other things elsewhere in the language

As postfix ! means null-safe a postfix ! would mean map-safe, so I think it is a pretty good and easy syntax:

switch (map) {
  case {'key': 1, 'other': 2}!: ...
}

@munificent
Copy link
Member

As postfix ! means null-safe a postfix ! would mean map-safe, so I think it is a pretty good and easy syntax:

The problem is that the exact syntax you propose here already does mean something. It's a null-assert pattern whose inner pattern is a map pattern.

@jodinathan
Copy link
Author

I do get where this comes from. It makes sense when you think of a Map as a sealed class with exhaustive options, however, IMHO, they are far away from having related usage.

I am creating a Typescript declaration file transpiller for Dart. It will basically create JS interop from .d.ts files. (It shall replace my js_bindings package)
It has two parts:

  • A small NodeJS app that uses the official Typescript analyzer to analyze the .d.ts and dumps the resulting json into a file
  • A big dart app that reads the json file and generates the js interop bindings.

As it is a new package I am using Dart 3 and most of the incoming features I can at this time.
I chosen JSON because of its dynamic structure and easy encoding/decoding between the apps.
99% of times that I had a ReachbilityError was because of missing ....

So in my perspective the syntax seems more valuable to use as map-safe than a syntax sugar for null assertion.

@munificent munificent reopened this Mar 27, 2023
@munificent
Copy link
Member

I'll bring this up at the language meeting this week. I share your concern, but I'm not certain what a better approach would look like. Also, we have almost no time to make changes so it may be too late anyway, but I'll see what the rest of the team things.

@jodinathan
Copy link
Author

@munificent thanks =]

@munificent munificent added the patterns Issues related to pattern matching. label Mar 29, 2023
@munificent
Copy link
Member

OK, I spent a bunch of time talking about this today with @leafpetersen, @jacob314, and @jakemac53. We'll discuss it with the rest of the language team tomorrow, but I wanted to write up my thoughts first:

TL;DR: I think we should change map patterns to do loose matching.

Concretely, I propose:

  • Map patterns no longer call .length and don't look at length to determine whether the pattern matches.

  • We remove support for ... elements from map patterns since it doesn't do anything with the above change.

  • We make it an error to have an empty map pattern. With the above two changes, it's confusing and not useful. If you just want to test that an object is a map without looking up any keys, use Map<K, V> _ or Map<K, V>().

Reasoning

First, let me go through the reasons why we made them strict in the current proposal and why I think those reasons aren't compelling:

Old proposal reasoning

It's consistent with list patterns

List patterns check the length by default, and you need to do ... if you want to allow lists of arbitrary length. I believe that's the right behavior for lists. With a list, the existence of any element changes the indexes of all elements after it. The elements are interrelated with each other.

If lists weren't strict by default, we'd have to figure out which elements get bound when the list pattern has fewer elements than the incoming list object. We'd probably say that it just matches a prefix. But if the list has more elements than you expect, what reason do you have to assume that the unexpected ones happen to be at the end? For all you know, they could have been prepended or interspersed with the elements you care about.

This is exactly why ... in list patterns can be placed anywhere in the pattern. Because the language can't confidently assume which elements you don't care about, it gives you the ability to tell it.

That doesn't apply to map patterns. In a map, every key is independent of any others. Adding more keys to a map doesn't "adjust" or "shift" any of the other keys. In fact, this is why you can only place ... at the end in a map pattern: because it wouldn't mean anything to put it elsewhere.

So I don't see a very compelling claim that map patterns should behave like list patterns. There's an argument that just being consistent with another kind of pattern is valuable. But the destructuring patterns already each have their own behavior and policy:

  • List patterns default to checking the length. They have a unique ... syntax that can be used to capture a sublist and control which other elements are extracted. List patterns have positional elements and only positional elements.

  • Object patterns have no notion of length at all. It's really just a collection of independent parallel operations. Sort of like a series of cascade expressions but for a pattern. Every subpattern must be named.

  • Record patterns don't have a notion of length, but they do have an even more rigid notion of shape. A record pattern only matches record objects that have the exact same set of fields as the pattern. The set of fields and their names is used to determine the type of record object that the pattern matches, so every field is an essential component. There's no ... support or way to ignore some fields. It supports both positional and named fields.

  • In map patterns, each entry is a key-value pair where the key is a constant, not a name. The ... can only appear at the end.

So, already, it seems like every pattern has a policy and behavior that's tuned for its own particular needs. Given that, I think map patterns should behave the way that makes the most sense for maps, and not try to be list-like when they aren't lists. If anything, they're closer to object patterns.

It provides an obvious syntax for both loose and strict matching

If we default to strict, then it makes sense to use ... to opt out of that and do loose matching. If we default to loose matching and then users want to opt in to length checks, we need to come up with a syntax for that.

But do we really? You can just use a guard:

switch (map) {
  case {'a': 1, 'b': 2} when map.length == 2: ...
}

Or if you want to be cute:

switch (map) {
  case {'a': 1, 'b': 2} && Map(length: 2): ...
}

But the more fundamental question to me is how often that behavior is even desired? I suspect that most uses of maps and destructuring in Dart are one of:

  1. Working with maps coming from external data where loose is the right behavior and you want to be able to silently ignore unmatched keys.

  2. Working with maps created internally where you do know the exact set of keys. For these... I think almost all of them should be migrated to use records instead.

If my suspicion is correct, then users will rarely need to opt into strict checking anyway. And, if they want to, then just doing an explicit length check in a guard is a pretty acceptable way to do it.

Map pattern order is less important

Consider this example with the current proposal:

print(switch (map) {
  {} => 'empty',
  {'a': _} => 'just a',
  {'b': _} => 'just b',
  {'a': _, 'b': _} => 'a and b',
  {...} => 'something else',
}

This basically works like the case bodies say. The first {} matches only empty maps. The next two cases match only a map with a single key. You get the idea.

If we make map patterns loose, this switch doesn't do what you want. The first {} pattern matches any map at all. That means all of the other cases are unreachable (and the compiler will tell you they are). Instead you have to write:

print(switch (map) {
  {'a': _, 'b': _} => 'a and b',
  {'a': _} => 'just a',
  {'b': _} => 'just b',
  {} when map.isEmpty => 'empty',
  {} => 'something else',
}

I do think this is one of the merits of the current proposal. If you think of maps as fairly "closed" and "disjoint" data structures, then the looser checking could trip you up. You'll have to think about ordering your map cases a little more carefully. But, if you get it wrong, the compiler should generally tell you by reporting that some later cases are unreachable.

However, I think most uses of maps don't treat them like a closed-world data structure. And, certainly, the language doesn't. That's why the {...} case is there at the end. You have to have a {...} case if you want to switch on a map because as far as the language knows, a map can have an unbounded number of keys. That's the value of maps: they are open-ended.

The current proposal encourages you to think of them as being record-like with a small number of well-known keys, but I think that's the wrong mental model. Consider an analogous switch over an object with a couple of getters:

print(switch (obj) {
  Obj() => '?',
  Obj(a: true) => 'a is true',
  Obj(b: true) => 'b is true',
  Obj(a: true, b: true) => 'a and b are true',
}

Here, it's clearer that the order is wrong. With an object pattern, you tend to want the cases with more subpatterns first since they are asking more specific questions about the underlying object.

I think that's the right mental model for maps too. A map is an open-ended data structure. When you query a key on it, that query is unrelated to other keys you might query.

However, making map patterns loose does mean that an empty map pattern is a footgun. That's why I suggest disallowing them. The point of a map pattern is to look up values by key. If you don't have any keys to look up, don't use a map pattern.

Proposed change reasoning

I think there is merit to the reasons we went with the old proposal, but overall I find the arguments for loose matching to be much stronger. In particular:

Loose matching is what users want most of the time

This is the most important part, by a large margin. When I look at imperative code today working with maps, it often looks like:

if (map.containsKey('a') && map['b'] == 'some value') {
  // Do stuff...
}

Note what it's not doing? It's not checking the length. Almost all of the code I see reading from maps is like this. And this is exactly the kind of code that I think should be migrated to map patterns.

That suggests that the syntax should optimize for this use case.

It's certainly the case that if we keep the current proposal, we'll have to tell users to be very careful when migrating their imperative code that uses maps to map patterns. Because if the code doesn't do any length checks, they need to remember to add ... to the pattern or they risk introducing runtime errors into their code.

Strict matching isn't helpful in irrefutable contexts

You can use map patterns in variable declarations and assignments too:

var {'a': a, 'b': b} = someMap;

In these contexts if the length check fails, it throws a runtime exception. That's not very helpful. It is helpful to throw an exception if the key isn't present. That makes sense: we can't complete the requested operation because the data isn't there. But if all of the keys you're looking up are present and accounted for, throwing a spurious runtime exception because it happens to have extra keys that the variable declaration or assignment doesn't care about doesn't add a lot of value.

The clear trend for Dart has been away from code that can fail at runtime. But this is a case where the default behavior can lead to a runtime exception and you have to opt out of it by using ....

If we ship the current proposal, I'm certain we'll end up with a lint that says "Do use ... in map patterns in variable declarations and assignments." I wouldn't be surprised if we ended up with a lint that just said, "Prefer using ... in all map patterns."

Strict matching isn't even well defined

The idea with strict matching is that a map pattern should match only maps that have the matched set of keys and no other. But "no other keys" isn't actually a well-defined concept across all reasonable implementations of Map. Even specifying how strict matching works was tricky, because how do you express "and nothing else" in terms of the Map API?

The best we could come up with is "the length must be the same as the number of entry subpatterns. But that's a pretty tenuous connection. Consider:

test(Map<String, String> map) {
  switch (map) {
    case {'a': var littleA, 'A': var bigA}: print('$littleA $bigA');
  }
}

With strict matching, this will only match a map where:

  • constainsKey('a') returns true.
  • constainsKey('A') returns true.
  • length is 2.

But things like [CaseInsensitiveMap][] exist and are reasonable. If you were to call that function with:

var map = CaseInsensitiveMap.from({'a': 'hi'});

Then I think there's a good argument that it should match that case even though it's length is 1. In general, length is a fairly fragile concept for maps, and I don't like that the map pattern syntax directly relies on it.

(This conceptual fragility is also why ... in map patterns doesn't accept a subpattern like the ... element in list patterns does. With a map, there's no clearly defined notion of "the remaining part of this map without these keys".)

Conclusion

Overall, I feel fairly confident that loose matching is the behavior users actually want almost all of the time. It's semantically simpler and avoids stepping into weird policy questions about the connection between map length and what keys it contains.

Loose checking makes maps safer to use in irrefutable contexts.

If a user does want strict matching, they can opt in to it by writing a when map.length == whatever check fairly easily.

Of course, it would have been great to think through all of this months ago. We are very very close to shipping this feature, so it's hard to change. But I really don't want to ship strict checking if it will just end up being frustrating and error-prone for users every single time they use map patterns.

I believe the scope of the change here is relatively small. It's:

  • Remove the .length checks from the runtime semantics.

  • Remove parser support for ... elements in map patterns.

  • Report an error for empty map patterns. (This change is optional but I think will make map patterns less error-prone.)

There would be a lot of tests to update and some docs. I'm not underestimating the cost of the churn here. But I do believe that our future selves will consider it worth it.

I deliberately do not propose any new syntax for opting in to strict checking to minimize the cost of the change. In a future release, we could consider adding that if it's something users want.

@lrhn
Copy link
Member

lrhn commented Mar 29, 2023

The reasoning is sound. My only nit is

We make it an error to have an empty map pattern.

... followed by examples using empty map patterns as if it's the most natural thing in the world. 😁

Let's just keep them, for consistency.
(I don't want to use "but won't anyone think of the code generators" as argument. They can and should do better than introducing irrelevant checks.)
The one reason to disallow is if we think people will read them as "check that the map is empty".
And it's quite possible that {} is read that way, even though {"a": 1} is not read as matching only a one-element map, because people map the patters to code they would otherwise write, and {"a": 1} maps to map["a"] == 1, whereas {} only maps to the existing code map.isEmpty.
(So, lets discuss this one.).

As you say, a map is defined by its containsKey and [] operators, the length is a derived and not-necessarily representative property for non-default maps. And the case-insensitive map is a (possibly bad) example where the two are not in sync.

(It's still a very questionable choice to make such a map. Just because something allows lookup, it doesn't have to be a Map. A quick check of the internal _CaseInsensitiveStringMap used for the process environment on Windows shows logic errors, containsKey doesn't agree with keys.contains, so it's not something you should just do without being careful. I hope the CanonicalizedMap in package:collection does the right thing all the way through, but I should check.)

I'm more open to the point that maps are intended for random-access, where lists are intended for iteration. You care about the length of a list. You don't, as often, care about all the keys of a map, just whether a particular one is there. When you do care about all the keys of a map, you'll be doing it by iteration, not by knowing the all keys up-front.

Maps are more like objects with properties that are individually useful. They have a key to give them significance and distinguish them from other entries. It makes sense to look at a single entry.

List elements have positions, but that's mostly an ordering, not a significant key. Looking at a specific element only is weird, unless its position is special in some way (like first or last).

And because length failures are runtime errors, forgetting the ... is a foot-gun.

It does prompt the question of whether a trailing ... is necessary or desired for lists.
Is prefix-matching common enough that the same argument applies to lists?
(Or should we add iterable patterns for that?)
You can test the length of a list using when as well, if necessary.
(I can think of use-cases where I want to match only lists of a specific length, but in Dart 3, I'd use records for those use-cases.)

@rubenferreira97
Copy link

rubenferreira97 commented Mar 29, 2023

After giving some thought, I personally don't find loose matching compelling. It feels that the developer intention is hidden, propitious to errors. With strong matching, what the developer write is what that object is (an explicit ..., says clearly that the map can be loose). It's type could be narrowed to the pattern type (Dart does not support this because there are no literal types). It also feels annoying to always check for the length of a map when we need the exact shape, and there are use cases for this that I can think like strong API contracts (like web services, protocols, etc...).

XOR type checks also become harder to express IMO.

final obj = { 'a' : 1, 'b ': 2, 'c' : 3, 'd ': 4, 'e' : 5};
String json = switch (obj) {
  case { "a": String a } => jsonEncode(obj),
  case { "b": String b } => jsonEncode(obj),
  _: throw Error('Bad shape'),
}

// we don't want to json serialize {"a" : 1, plus a million fields} and send it to a client. Send 'a' XOR 'b'

A different topic but this feels like a related design problem present in TypeScript that is hard to express a XOR. Since types are loose, TypeScript can't express bar: string /*^XOR^*/ can: number; really well. Tricks like { bar: string; can?: never } | { bar?: never; can: number }; are needed.
Many typescript users expect {'a' : string} | {'b': string} to be an XOR and not an OR, which seems to imply users find loose types fine until they need to express themselves more precisely. I know, a completely different problem but I think the same mental overhead will be hit with this proposal.


The reasoning is sound. My only nit is

We make it an error to have an empty map pattern.

... followed by examples using empty map patterns as if it's the most natural thing in the world. 😁

Let's just keep them, for consistency.

Personally, treating {} (strong matching) differently than {'a': String a} (loose matching) seems completely opposite to consistency 🙂.

I would prefer case Map(length: 0).

@jodinathan
Copy link
Author

there are use cases for this that I can think like strong API contracts (like web services, protocols, etc...

Imagine that the service you are using is passing though a hard time regarding performance and they insert some harmless profilling info in their response like {"_": 123456}.
Or they just started using a observability service that adds a layer to their API and automatically insert log info in the response.
Bam, caos everywhere.

Just like Bob said, we never check for a map length... do we?

You can't trust a Map structure as a whole.

thanks @munificent

@rubenferreira97
Copy link

rubenferreira97 commented Mar 29, 2023

In that case I would explicity add ..., which would not make any runtime length check and also make my intent clear and explicit. Not all APIs are loose, there are strong/strict APIs.

IMO it's dangerous to implicity "ignore/consume" variables.

final obj = { 'a': 1, 'b': 2};
switch (obj) {
  case { 'a': String a }: print('a');
  case { 'a': String a, 'b': String b } : print('ab');
}

With this proposal this code will silently print a. Statement order matters a lot more now. The same problem happens with adding ... with strong matching, but I would assume the developer know the consequences of adding that in the first statement.

@jodinathan
Copy link
Author

In that case I would explicity add ...

Would you rather fix your logic while developing or in production?

You can check for ordering of statements at any time.

@mateusfccp
Copy link
Contributor

Would you rather fix your logic while developing or in production?

If you know that the model is subject to change, use .... It's that simple.


Overall, I am not sure about what's the best approach.
On one side, I don't think adding ... is hard and I like it's explicit. I also think that maps should be strictly matched, and using length is just terrible.

On the other side, I agree with the "Strict matching isn't helpful in irrefutable contexts" point, and I think that in the specific given example we would want loose matching to avoid this kind of runtime exceptions.

Overall, I am more inclined to strict matching, but I see some value in loose matching too.

@munificent
Copy link
Member

We talked about this in the language meeting this morning and the team is on board with changing it. We need to make sure the implementers are OK with it because it's very late in the cycle to be making a change, but so far it seems promising.

Thanks for pushing on this @jodinathan. I think this will make Dart a better language and we wouldn't have revisited this design choice without you and others bringing it to our attention.

XOR type checks also become harder to express IMO.

final obj = { 'a' : 1, 'b ': 2, 'c' : 3, 'd ': 4, 'e' : 5};
String json = switch (obj) {
  case { "a": String a } => jsonEncode(obj),
  case { "b": String b } => jsonEncode(obj),
  _: throw Error('Bad shape'),
}

With this proposal, you can still get the exact previous behavior by doing:

final obj = { 'a' : 1, 'b ': 2, 'c' : 3, 'd ': 4, 'e' : 5};
String json = switch (obj) {
  case { "a": String a } when json.length == 1 => jsonEncode(obj),
  case { "b": String b } when json.length == 1 => jsonEncode(obj),
  _ => throw Error('Bad shape'),
}

Or if you really want to make it clearer that you're doing XOR:

final obj = { 'a' : 1, 'b ': 2, 'c' : 3, 'd ': 4, 'e' : 5};
String json = switch (obj) {
  case { "a": String a } when !json.containsKey('b') => jsonEncode(obj),
  case { "b": String b } when !json.containsKey('a') => jsonEncode(obj),
  _ => throw Error('Bad shape'),
}

Or simply handle the OR case first:

final obj = { 'a' : 1, 'b ': 2, 'c' : 3, 'd ': 4, 'e' : 5};
String json = switch (obj) {
  case { "a": String _, "b": String _ } => throw Error('Bad shape'),
  case { "a": String a } => jsonEncode(obj),
  case { "b": String b } => jsonEncode(obj),
  _ => throw Error('Bad shape'),
}

The latter is what I'd probably do because it makes it clear which keys are mutually exclusive while gracefully handling extra keys you don't care about.

// we don't want to json serialize {"a" : 1, plus a million fields} and send it to a client. Send 'a' XOR 'b'

In that case, it's better to create a map yourself with just the data you want instead of blindly forwarding a data structure you don't control:

final obj = { 'a' : 1, 'b ': 2, 'c' : 3, 'd ': 4, 'e' : 5};
String json = switch (obj) {
  case { "a": String _, "b": String _ } => throw Error('Bad shape'),
  case { "a": String a } => jsonEncode({'a': a}),
  case { "b": String b } => jsonEncode({'b': b}),
  _ => throw Error('Bad shape'),
}

IMO it's dangerous to implicity "ignore/consume" variables.

final obj = { 'a': 1, 'b': 2};
switch (obj) {
  case { 'a': String a }: print('a');
  case { 'a': String a, 'b': String b } : print('ab');
}

With this proposal this code will silently print a.

With this proposal (and with the current behavior if you have ... in the maps), you'll get a compile warning that the second case is unreachable. So the language does give you guidance if your cases are ordered in a way that makes some unreachable and useless.

Statement order matters a lot more now.

This is definitely true. But the same is true for object patterns, which I thinkn map patterns are conceptually closest to for most common use cases.

I do worry that this will be a little error-prone, but the exhaustiveness and reachability errors should help. There's a fundamental concept here where when a user looks at a pattern, do they see it as describing the entire object being matched, or do they see it as describing a filter over a set of objects to match?

If they have the former mindset, then shorter patterns generally describe more specific objects and match fewer things. That leads to a mindset where {} only matches the empty map. If they have the former mindset, then shorter patterns generally describe more general objects (because they place fewer constraints on it) and thus match more things. From that mindset, {} clearly goes last because it matches just about everything.

A real challenge with pattern matching is that both mindsets are intuitive but one or the other can lead you astray in some situations. I don't think there's a perfect solution but we're trying to pick what we think is the most intuitive for most uses and the most terse for most common use cases. Balancing all that is hard. Doing it in a language that wasn't already designed around patterns is really hard. :)

copybara-service bot pushed a commit to dart-lang/sdk that referenced this issue Mar 30, 2023
… pattern.

Bug: dart-lang/language#2861
Change-Id: I00ccb3ea03aa476f96c2ecf3e3a9e13bd4926193
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/291940
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Reviewed-by: Johnni Winther <johnniwinther@google.com>
Reviewed-by: Marya Belanger <mbelanger@google.com>
Commit-Queue: Konstantin Shcheglov <scheglov@google.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
patterns Issues related to pattern matching.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants