polar

Handling Errors

Polar has throw and try/catch, like JavaScript, with one difference: the errors a function can throw are part of its type, so nothing is thrown that the caller doesn't know about.

Throwing an error

An error is an ordinary value, usually a variant case that describes what went wrong:

types
  Missing = Missing(String)

functions
  balance(name: String) -> Int / {Throws<Missing>} {
    match name {
      "ann" -> 120,
      "bob" -> 15,
      _ -> throw Missing(name),
    }
  }

throw stops the function and hands the error up. Throwing is an effect, Throws<Missing>, so it shows up in the signature next to any other effects.

Errors travel up

If you call a function that throws and don't catch the error, it passes through you, and you have to declare it too. A function can throw more than one kind of error:

types
  TooPoor = TooPoor(Int)

functions
  withdraw(name: String, amount: Int) -> Int / {Throws<Missing>, Throws<TooPoor>} {
    let current = balance(name)

    if current < amount { throw TooPoor(current) } else { current - amount }
  }

withdraw throws TooPoor itself, and lets Missing from balance pass through.

Catching errors

try runs a block. If something inside it throws, the matching catch arm runs instead. The arms are patterns, just like in match:

try_withdraw(name: String, amount: Int) -> String {
  try {
    "#{name} has #{withdraw(name, amount)} left"
  } catch {
    Missing(n) -> "no account for #{n}",
    TooPoor(left) -> "#{name} only has #{left}",
  }
}

Look at the signature: try_withdraw is pure. Both errors are handled inside it, so its callers never see them, and the type says so.

Tip: try is an expression, like if and match. Both the block and every catch arm produce a value of the same type.

Uncaught errors

An error that reaches the top of main without being caught is reported, and the program exits with code 1:

$ polar run uncaught.px
uncaught error: Missing("bob")

Assertions

Std.Assert has assert, equal, not_equal and fail. Each throws a Failed error when its check fails. They are what polar test tests are made of, and handy for quick checks in examples:

uses
  Std.Assert

functions
  main() {
    Assert.assert(1 + 1 == 2)
  }

Throws or Result?

Polar gives you two ways to report failure. Here's how to choose:

  • Use Result when the caller is expected to deal with the failure right away, like parsing user input. It's a value you pass around and match on. See Result.
  • Use throw when the failure should skip over several layers of code to wherever it's handled, without every function in between checking for it.

All together

module Accounts

uses
  Std.Assert

types
  Missing = Missing(String)

  TooPoor = TooPoor(Int)

functions
  balance(name: String) -> Int / {Throws<Missing>} {
    match name {
      "ann" -> 120,
      "bob" -> 15,
      _ -> throw Missing(name),
    }
  }

  withdraw(name: String, amount: Int) -> Int / {Throws<Missing>, Throws<TooPoor>} {
    let current = balance(name)

    if current < amount { throw TooPoor(current) } else { current - amount }
  }

  try_withdraw(name: String, amount: Int) -> String {
    try {
      "#{name} has #{withdraw(name, amount)} left"
    } catch {
      Missing(n) -> "no account for #{n}",
      TooPoor(left) -> "#{name} only has #{left}",
    }
  }

  main() {
    Log.info(try_withdraw("ann", 50))
    Log.info(try_withdraw("bob", 50))
    Log.info(try_withdraw("cat", 50))
    Assert.assert(try_withdraw("ann", 20) == "ann has 100 left")
    Assert.assert(1 + 1 == 3)
  }

exports
  main
$ polar run accounts.px
ann has 70 left
bob only has 15
no account for cat
uncaught error: Failed("false")

The first assertion passes. The second fails, and since nothing catches it, the program stops with exit code 1.

Effects tell Polar what a function does. Next, we'll see how Polar uses that to decide where a function can run: Hosts.