polar

Variants and Matching

A variant is a value that is one of several cases. Together with match, variants replace many of the if chains, null checks and string tags you'd write in JavaScript.

Declaring a variant

List the cases, separated by |. Case names start with a capital letter, and a case can carry values:

types
  Shape = Circle(Float) | Rect(Float, Float)

  Status = Draft | Published(String) | Archived

A Shape is either a circle with a radius, or a rectangle with a width and height. Make one by calling the case like a function: Circle(1.0) or Rect(2.0, 3.0). Cases with no values, like Draft, are written on their own.

Matching on a variant

match looks at a value and runs the arm whose pattern fits:

area(shape: Shape) -> Float {
  match shape {
    Circle(r) -> 3.14 * r * r,
    Rect(w, h) -> w * h,
  }
}

Each arm is pattern -> result, and arms are separated by commas. The pattern Circle(r) matches circles and names the radius r for that arm. Like if, match is an expression, so its value is the value of the arm that ran.

Every case is covered

Say we add a Triangle case to Shape and forget to update area. In JavaScript that's a bug you'd find at runtime, if you're lucky. Polar finds it right away:

error[POLAR0601]: this `match` doesn't cover every case
 --> exhaust.px:8:5
  |
8 |     match shape {
  |     ^^^^^^^^^^^ `Triangle(_, _)` is not covered

This is one of the most useful things a type checker can do. When you add a case, the compiler gives you a list of every place that needs to handle it.

Patterns

match works on any value, not only variants. These are the patterns you can use:

Pattern Matches
0, "ann", true Exactly that value
_ Anything, and ignores it
name Anything, and names it
Published(date) A case, naming its values
[] An empty list
[a], [a, b] A list of exactly that length
[x, ..rest] A list with at least one item, and the rest of it
{ x: 0, y: 0 } A record whose fields match

Patterns nest, so Some([x, ..rest]) matches an optional, non-empty list. Here are a few in use:

count(n: Int) -> String {
  match n {
    0 -> "none",
    1 -> "one",
    _ -> "many",
  }
}

sum(xs: List<Int>) -> Int {
  match xs {
    [] -> 0,
    [x, ..rest] -> x + sum(rest),
  }
}

first_two(xs: List<String>) -> String {
  match xs {
    [] -> "nothing",
    [a] -> "just #{a}",
    [a, b, ..rest] -> "#{a} and #{b}, plus #{List.length(rest)} more",
  }
}

Arms are tried top to bottom, so put specific patterns before general ones.

Note: _ covers every remaining case, which also silences the "not covered" error. Prefer listing cases by name when you can, so the compiler can still warn you when a new case appears.

All together

module Variants

uses
  Std.List

types
  Shape = Circle(Float) | Rect(Float, Float)

  Status = Draft | Published(String) | Archived

functions
  area(shape: Shape) -> Float {
    match shape {
      Circle(r) -> 3.14 * r * r,
      Rect(w, h) -> w * h,
    }
  }

  label(status: Status) -> String {
    match status {
      Draft -> "draft",
      Published(date) -> "published on #{date}",
      Archived -> "archived",
    }
  }

  count(n: Int) -> String {
    match n {
      0 -> "none",
      1 -> "one",
      _ -> "many",
    }
  }

  sum(xs: List<Int>) -> Int {
    match xs {
      [] -> 0,
      [x, ..rest] -> x + sum(rest),
    }
  }

  first_two(xs: List<String>) -> String {
    match xs {
      [] -> "nothing",
      [a] -> "just #{a}",
      [a, b, ..rest] -> "#{a} and #{b}, plus #{List.length(rest)} more",
    }
  }

  main() {
    Log.info("#{area(Circle(1.0))} #{area(Rect(2.0, 3.0))}")
    Log.info(label(Published("May 4")))
    Log.info("#{count(0)}, #{count(1)}, #{count(7)}")
    Log.info("#{sum([1, 2, 3, 4])}")
    Log.info(first_two(["a", "b", "c", "d"]))
    Log.info(first_two(["solo"]))
  }

exports
  main
$ polar run variants.px
3.14 6
published on May 4
none, one, many
10
a and b, plus 2 more
just solo

The standard library uses variants for its most important types. Let's meet them next: lists, options and results.