Pattern Matching

GitHub   Edit on GitHub

Pattern matching is a powerful tool to work with data structures. It’s like a switch statement in other languages, but with a bit more pizazz. Each case of a match statement defines the shape, or pattern, of the data that will match the case.


Matching Enum Types

Let’s start by looking at a simple enum type.

1
2
3
4
5
6
7
8
9
10
11
12
module Main

enum PizzaTopping { Cheese, Pepperoni, Peppers, Pineapple }

let topping = Peppers

match (topping) {
Cheese => print("Would it really be pizza without it?"),
Pepperoni => print("An instant classic."),
Peppers => print("For those who like to spice things up."),
Pineapple => print("You do you.")
}

This example prints out a fun message depending on which pizza topping is bound to topping. Each case is separated by a comma.

You can also surround the body of your match case with curly braces to include more statements.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module Main

enum PizzaTopping { Cheese, Pepperoni, Peppers, Pineapple }

let getPrice = (topping) => {
match (topping) {
Cheese => 4,
Pepperoni => 6,
Peppers => {
print("Peppers are on sale this week!")
3
},
Pineapple => 9
}
}

getPrice(Peppers)

Default Cases

If we only care about some of the cases, we can use an underscore pattern to match all other possible cases.

1
2
3
4
5
6
7
8
9
10
module Main

enum PizzaTopping { Cheese, Pepperoni, Peppers, Pineapple }

let topping = Peppers

match (topping) {
Cheese => print("Would it really be pizza without it?"),
_ => print("That's cool, but the cheese makes the pizza.")
}

If we were to omit the last underscore “catch-all” case, the Grain compiler would complain that we’re missing cases for Pepperoni, Peppers, and Pineapple.

Nested Match Patterns

We can nest match patterns as deeply as we’d like. If we sold both one-topping pizzas and calzones, we may want to single out a particular pizza/topping combo:

1
2
3
4
5
6
7
8
9
10
11
12
module Main

enum Topping { Cheese, Pepperoni, Peppers, Pineapple }
enum Menu { Pizza(Topping), Calzone(Topping) }

let item = Calzone(Peppers)

match (item) {
Calzone(Peppers) => print("These are half off this week."),
Pizza(Cheese) => print("We never discount this item."),
_ => print("No current specials.")
}

We can also use an underscore anywhere within a pattern to match remaining cases.

1
2
3
4
5
6
7
8
9
10
11
12
module Main

enum Topping { Cheese, Pepperoni, Peppers, Pineapple }
enum Menu { Pizza(Topping), Calzone(Topping) }

let item = Calzone(Peppers)

match (item) {
Calzone(Peppers) => print("These are half off this week."),
Calzone(_) => print("Enjoy 10% off, on us."),
_ => print("No current specials.")
}

Bindings in Match Patterns

We can bind portions of a match pattern to a name and use that bound value in the body the corresponding case.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module Main

enum Topping { Cheese, Pepperoni, Peppers, Pineapple }
enum Menu {
Calzone(Topping),
Pizza{
stuffedCrust: Bool,
topping: Topping
}
}

let checkSpecials = topping => {
match (topping) {
Cheese => true,
Pepperoni => true,
_ => false,
}
}

let item = Calzone(Peppers)

match (item) {
Calzone(topping) => {
if (checkSpecials(topping)) {
print("These are half off this week.")
}
},
Pizza{stuffedCrust, topping} => {
if (!stuffedCrust && checkSpecials(topping)) {
print("These are 30% off this week.")
}
}
}

One common use of pattern matching and binding is working with Option and Result enums.

1
2
3
4
5
6
7
8
9
10
11
12
13
module Main

let opt = Some("I'm a match")
match (opt) {
Some(msg) => print(msg),
None => print("No match found")
}

let result = Err("This is an error")
match (result) {
Ok(msg) => print(msg),
Err(e) => print(e)
}

The first example will print I'm a match because the Option has a value.

The second example will print This is an error because the Result contains an Err.


Matching Record Types

Like most Grain data structures, pattern matching can also be done on records.

1
2
3
4
5
6
7
8
9
module Main

record Person { name: String, age: Number }

let person = { name: "Steve", age: 25 }

match (person) {
{ name, age } => print(name)
}

If we don’t care about some of the record fields, we can use an underscore to tell the Grain compiler that we’ve intentionally left those fields out.

1
2
3
4
5
6
7
8
9
module Main

record Person { name: String, age: Number }

let person = { name: "Steve", age: 25 }

match (person) {
{ name, _ } => print(name)
}

Nested Matching Within Records

Things are a bit more interesting when we have data structures nested within records.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module Main

enum Topping { Cheese, Pepperoni, Peppers, Pineapple }
enum Order { Pizza(Topping), Calzone(Topping) }

record Person { name: String, order: Order }

let person = { name: "Steve", order: Calzone(Pepperoni) }

match (person) {
{ order: Pizza(_), _ } => print("All pizzas are great here."),
{ order: Calzone(Peppers), _ } => print("Someone with great taste!"),
{ order: _, _ } => print("Yep, that's an order.")
}

Matching Tuples

Pattern matching can also be performed on tuples.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module Main

enum Topping { Cheese, Pepperoni, Peppers, Pineapple }
enum Order { Pizza(Topping), Calzone(Topping) }
enum OrderType { DineIn, Takeaway }

let order = (Calzone(Pineapple), DineIn)

match (order) {
(Calzone(Pineapple), DineIn) => {
print("We can't let the other customers see this.")
},
(_, Takeaway) => print("Box it up to go."),
(_, DineIn) => print("Clean off table 5.")
}

Matching Lists

Often with lists, we want to do something with the first element of a list and then do some further processing with the rest of the list.

In this example, we define an add2 function to increment each value in a list of numbers by 2.

1
2
3
4
5
6
7
8
9
10
module Main

let rec add2 = (list) => {
match (list) {
[first, ...rest] => [first + 2, ...add2(rest)],
[] => []
}
}

add2([1, 2, 3]) // [3, 4, 5]

Let’s break this down.

The [first, ...rest] pattern creates bindings for the first element in the list, and a list containing the rest of the elements. In the body of this case, the [first + 2, ...add2(rest)] expression creates a new list. The first element of the result is the sum of 2 and the first element of the original list. The remaining elements of the list are the result of a recursive call to the add2 function on the rest of the original list.

We can also match on multiple elements at the beginning of a list:

1
2
3
4
5
6
7
8
module Main

let list = [1, 2, 3]

match (list) {
[first, second, ..._] => first + second,
_ => fail "List contained fewer than 2 elements"
}

Finally, matches can also be performed on lists with specific lengths.

1
2
3
4
5
6
7
8
9
10
11
module Main

let list = [1, 2, 3]

match (list) {
[] => print("List contains no elements"),
[_] => print("List contains one element"),
[_, _] => print("List contains two elements"),
[_, _, _] => print("List contains three elements"),
_ => print("List containes more than 3 elements")
}

Match Guards

Sometimes you want to be more specific about conditions for matching. You can use a match guard to place more specific limitations on a match case.

You can think of a match guard as a combination of a match and an if. A guard allows you to add an additional statement to qualify whether the case matches.

1
2
3
4
5
6
7
8
module Main

let myNumber = Some(123)
match (myNumber) {
Some(val) when val > 100 => print("More than 100"),
Some(val) => print("Less than or equal to 100"),
None => print("Nothing at all")
}

In the example above, myNumber is an Option whose value is Some(123). But our match statement has three cases instead of two. The first case adds a guard: Some(val) when val > 100. This case will only be matched when the Option is Some() and the value inside that option is a number greater than 100.

The next case, Some(val), will match any Some() value that is not matched by the guarded version.

Order is important when using guards. The first case to match is the one that will be used. For example, we could change the above example to this:

1
2
3
4
5
6
7
8
module Main

let myNumber = Some(123)
match (myNumber) {
Some(val) => print("Less than or equal to 100"),
Some(val) when val > 100 => print("More than 100"),
None => print("Nothing at all")
}

In this version, regardless of the size of myNumber, it would always match the first case.

Even default cases can have guards.

1
2
3
4
5
6
7
8
9
module Main

let myNumber = Some(99)
let isTuesday = true
match (myNumber) {
Some(val) when val > 100 => print("Greater than 100"),
_ when isTuesday => print("It's Tuesday"),
_ => print("Nothing else matched")
}

In the example above, if myNumber is greater than 100 (which it is not), then the program prints Greater than 100. Otherwise, if isTuesday is true, then it prints It's Tuesday (regardless of whether myNumber is a Some() or a None). Finally, if nothing else matches, the program prints Nothing else matched.

This is a notification!