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 toList.map. Becausemappasses effects through, theThrows<BadInput>reaches ourtry. -
The signature says
/ {Process}and nothing else. Thetrycaught everyBadInput, so no error can escapemain.
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.