polar

Thinking in Polar

Let's build a real program and see how the pieces fit. We'll write grades, a command-line tool that turns scores into a report.

Here's what it will do when we're finished:

$ polar run grades.px -- ann:93 bob:71 cat:85
ann: 93 (A)
bob: 71 (C)
cat: 85 (B)
Average: 83
Best: ann

Along the way, we'll follow the approach that works best in Polar: model the data first, keep the core of the program pure, and push effects to the edges.

Step 1: Model the data

Start with types, before any logic. What does the program work with? A score is a name and a number of points, and a grade is one of four letters:

types
  Score = { name: String, points: Int }

  Grade = A | B | C | F

Score is a record, because every score has both fields. Grade is a variant, because a grade is one of the cases. Getting this choice right makes the rest of the code simple.

Tip: Why not store grades as strings like "A"? Because then "a", "E" and "" would be grades too. With a variant, only real grades exist, and every match on one is checked for completeness.

Step 2: Write the pure core

Next, the logic. None of it needs the outside world, so these are all pure functions. They're easy to test, and they can run on any host.

grade(points: Int) -> Grade {
  if points >= 90 { A } else if points >= 75 { B } else if points >= 60 { C } else { F }
}

letter(g: Grade) -> String {
  match g {
    A -> "A",
    B -> "B",
    C -> "C",
    F -> "F",
  }
}

average(scores: List<Score>) -> Int {
  let total = List.fold(scores, 0, function(sum, s) { sum + s.points })

  total / List.length(scores)
}

For the best score, there's a catch: an empty list has no best. So best returns an Option, and the type reminds every caller to handle the empty case:

best(scores: List<Score>) -> Option<Score> {
  List.fold(
    scores,
    None,
    function(top, s) {
      match top {
        None -> Some(s),
        Some(t) -> if s.points > t.points { Some(s) } else { top },
      }
    },
  )
}

Then the report itself: one line per score, and a summary at the end.

line(s: Score) -> String {
  "#{s.name}: #{s.points} (#{letter(grade(s.points))})"
}

report(scores: List<Score>) -> List<String> {
  let top = Option.with_default(
    Option.map(best(scores), function(s) { s.name }),
    "nobody",
  )
  let summary = ["Average: #{average(scores)}", "Best: #{top}"]

  List.append(List.map(scores, line), summary)
}

Step 3: Parse the input, and fail clearly

Input arrives as text like ann:93. Text can be wrong, so parsing can fail. We describe the failure as a type, and throw it:

types
  BadInput = BadInput(String)

functions
  parse(arg: String) -> Score / {Throws<BadInput>} {
    match String.split(arg, ":") {
      [name, points] -> match Int.parse(points) {
        Some(n) -> { name: name, points: n },
        None -> throw BadInput(arg),
      },
      _ -> throw BadInput(arg),
    }
  }

The patterns do most of the work. [name, points] only matches when the text splits into exactly two parts. Int.parse returns an Option, so the non-number case can't be forgotten.

Step 4: Add effects at the edge

Only now do we touch the outside world. main reads the command-line arguments with Std.Process, runs the pure core, and sets an exit code:

main() -> {} / {Process} {
  try {
    let scores = List.map(Process.args(), parse)

    if List.is_empty(scores) {
      Log.error("usage: grades name:points ...")
      Process.set_exit_code(2)
    } else {
      Log.info(List.join(report(scores), "\n"))
    }
  } catch {
    BadInput(arg) -> {
      Log.error("can't read `#{arg}`, expected name:points")
      Process.set_exit_code(1)
    }
  }
}

Two things to notice:

  • List.map(Process.args(), parse) passes a throwing function to List.map. Because map passes effects through, the Throws<BadInput> reaches our try.
  • The signature says / {Process} and nothing else. The try caught every BadInput, so no error can escape main.

Step 5: Check where it runs

Ask Polar where each function can run:

$ polar build grades.px --emit=hosts
average : every host
best    : every host
grade   : every host
letter  : every host
line    : every host
main    : {Node}
parse   : every host
report  : every host

This is the payoff of keeping effects at the edge. Everything except main runs anywhere. If we later want a web version, the whole core moves to the browser unchanged, and only a new main needs to be written.

The whole program

module Grades

uses
  Std.List
  Std.Option
  Std.Process

hosts
  Node

types
  Score = { name: String, points: Int }

  Grade = A | B | C | F

  BadInput = BadInput(String)

functions
  grade(points: Int) -> Grade {
    if points >= 90 { A } else if points >= 75 { B } else if points >= 60 { C } else { F }
  }

  letter(g: Grade) -> String {
    match g {
      A -> "A",
      B -> "B",
      C -> "C",
      F -> "F",
    }
  }

  average(scores: List<Score>) -> Int {
    let total = List.fold(scores, 0, function(sum, s) { sum + s.points })

    total / List.length(scores)
  }

  best(scores: List<Score>) -> Option<Score> {
    List.fold(
      scores,
      None,
      function(top, s) {
        match top {
          None -> Some(s),
          Some(t) -> if s.points > t.points { Some(s) } else { top },
        }
      },
    )
  }

  parse(arg: String) -> Score / {Throws<BadInput>} {
    match String.split(arg, ":") {
      [name, points] -> match Int.parse(points) {
        Some(n) -> { name: name, points: n },
        None -> throw BadInput(arg),
      },
      _ -> throw BadInput(arg),
    }
  }

  line(s: Score) -> String {
    "#{s.name}: #{s.points} (#{letter(grade(s.points))})"
  }

  report(scores: List<Score>) -> List<String> {
    let top = Option.with_default(
      Option.map(best(scores), function(s) { s.name }),
      "nobody",
    )
    let summary = ["Average: #{average(scores)}", "Best: #{top}"]

    List.append(List.map(scores, line), summary)
  }

  main() -> {} / {Process} {
    try {
      let scores = List.map(Process.args(), parse)

      if List.is_empty(scores) {
        Log.error("usage: grades name:points ...")
        Process.set_exit_code(2)
      } else {
        Log.info(List.join(report(scores), "\n"))
      }
    } catch {
      BadInput(arg) -> {
        Log.error("can't read `#{arg}`, expected name:points")
        Process.set_exit_code(1)
      }
    }
  }

exports
  main

Try it with good input, bad input and no input:

$ polar run grades.px -- ann:93 bob:71 cat:85
ann: 93 (A)
bob: 71 (C)
cat: 85 (B)
Average: 83
Best: ann

$ polar run grades.px -- ann:93 bob
can't read `bob`, expected name:points

$ polar run grades.px
usage: grades name:points ...

The three runs exit with codes 0, 1 and 2.

Where to go from here

That's the main concepts covered. The Advanced Guides go deeper into specific tasks: storing state, JSON, running in the browser, calling JavaScript and more. Each one stands on its own, so read them in any order.