Pattern matching part 1

We've seen how to conditionally evaluate programs with the if true then "this" else "that" syntax. With pattern matching we can change the control flow of our program based on a richer set of conditions. We'll explore how to pattern match on literal values, add "guards" for more granular conditions for our pattern, and explore structural pattern matching based on data types.

Pattern matching on literals

In some circumstances you might see pattern matching being used as a concise chain of if / else statements.

foodUnit : Text -> Text
foodUnit f =
  "👇 compare f with the following..."
  match f with
    "Pie"     -> "slice"
    "Coffee"  -> "cup"
    "Soup"    -> "bowl"
    "Pancake" -> "stack"
    _         -> "???"

The first part of the pattern match syntax match f with identifies the target value that the pattern match will make its comparisons against. After the with keyword, Unison expects a series of arrow -> separated cases. In the example above, the value f will be compared against each value to the left of the arrow, starting from the top. The order in which the cases appear is important for the pattern match because if the value of f matches something on the left, Unison will evaluate the right side of the arrow.

In our example, we've provided a "catch-all" case at the end of the pattern match. It starts with an underscore: _ -> "fallback value"

🎨 It's a common practice to provide a catch-all. The following code will currently typecheck, but willlikely be a type error in a future version of Unison:

mySoupCount : Text -> Nat
mySoupCount name =
  "👇 no fallback value"
  match name with
    "Chicken Noodle" -> 4
    "Miso"           -> 7
    "Borscht"        -> 5
    "Chowder"        -> 5

mySoupCount "Gazpacho"

The error returned by the UCM reads:

💔💥

 I've encountered a pattern match failure in function `mySoupCount` while scrutinizing:

   "Gazpacho"

 This happens when calling a function that doesn't handle all possible inputs
 I'm sorry this message doesn't have more detail about the location of the failure. My makers plan
 to fix this in a future release.

Variables in pattern matching

Let's say we want to save a value on the left-hand side of the case statements for use when evaluating the expression on the right-hand side. Let's do that below:

use Universal
magicNumber : Nat -> Text
magicNumber guess = match guess with
  42 -> "magic 🪄"
  n -> toText n ++ " is not the magic number. Guess again."

Here n is a variable that will bind to any value of type Nat. It's also functioning as a fallback value, and in the example above whatever value it has can be used to produce a desired Text result.

Guard patterns

Guard patterns are a way we can build further conditions for the match expression. They can reference variables in the case statement to further refine if the right side of the arrow should be run. A guard pattern is started with pipe character | where the right of the | needs to return something of type Boolean. If the value to the right side of the pipe | is true, the block on the right of the arrow will be executed.

use Universal
use Text
matchNum : Nat -> Text
matchNum num = match a with
  oneTwo | (oneTwo === 1) || (oneTwo === 2) -> "one or two"
  threeFour | (threeFour === 3) || (threeFour === 4) -> "three or four"
  fiveSix | (fiveSix === 5) || (fiveSix === 6) -> "five or six "
  _ -> "no match"

We've combined variables with guard patterns and boolean operators in our pattern match statement. The variable oneTwo without the guard pattern would match anything of type Nat, but we can specify in the pattern guard that the variable oneTwo should either be the literal value 1 or 2.

You might see multiple guard patterns being used for the same match expression case. For example:

myMatch : Nat -> Text
myMatch num = match num with
  n
    | n < 3 -> "small number"
    | n > 100 -> "big number"
    | otherwise -> "medium number"

Each pipe | is followed by a boolean expression and an expression to execute if the boolean is true. The last case | otherwise -> in this syntax is a pass-through to catch all other cases. In the context of this pattern match form, otherwise simply means true.

Cases syntax

When writing a pattern match where the last value or set of values are being compared against successive cases, or "scrutinized", you can use the cases shorthand rather than writing out the full match statement.

So a match expression with one argument might look like this when fully written out:

myMatch : Nat -> Text
myMatch nat = match nat with
  n | n < 3 -> "small number"
  _ -> "big number"

But it can also be expressed as:

myMatch : Nat -> Text
myMatch = cases
  n | n < 3 -> "small number"
  _ -> "big number"

At first glance it might appear that there is no argument to the myMatch function, but when we see the cases keyword, we know that there is at least one inferred argument being tested against the cases in our match expression.

Two or more arguments can also be scrutinized with the cases syntax, but when multiple arguments are being tested, they are comma separated in the case statements.

twoCases : Nat -> Nat -> Text
twoCases = cases
  n1, n2 | n1 === n2 -> "same value"
  _, _ -> "different values"

📌

Summary:

  • Pattern matching allows us to inspect both the content the structure of our data.
  • Features like guard patterns and variable patterns allow us to apply conditions which may be local to the structure of that data to direct the flow of a program.
  • Guard patterns are expressed with a single pipe, |, with a boolean expression on the right.
  • The match ... with syntax can by simplified with cases when matching on the last arguments to a function.