Error handling
Agency does not have exceptions:
- Exceptions can crash your program
- Exceptions can't be represented in the type system
Instead Agency has the Result type.
The Result type
When you write a function you can either return a plain value:
def divide(a: number, b: number): number {
return a / b;
}Now if users divide by zero, you're out of luck. Instead, you can return a Result, which can be a success or a failure:
def divide(a: number, b: number): Result {
if (b == 0) {
return failure("Can't divide by zero!")
}
return success(a / b)
}Now, divide returns a Result type. You can unwrap it to see if it's a success or a failure:
const result = divide(10, 0)
if (isSuccess(result)) {
return "The result is ${result.value}"
} else {
return "Error: ${result.error}"
}Extra data
The first argument to the failure function is always a string error message. You can have an optional second argument where you can pass in an object containing extra data.
def parseConfig(path: string): Result {
return failure("Bad syntax in ${path}", { line: 4, column: 12 })
}You can read the message with .error and the extra data with .data:
const result = parseConfig("app.json")
if (isFailure(result)) {
print("${result.error} (line ${result.data.line})")
}.data is an empty object when the producer passed none, so reading a field off it gives null rather than an error.
Unwrapping, continued
Or more idiomatically with pattern matching:
const result = divide(10, 0)
if (result is success(value)) {
return "The result is ${value}"
} else {
return "Error: ${result.error}"
}Or:
const result = divide(10, 0)
return match (result) {
success(value) => "The result is ${value}"
failure(error) => "Error: ${error}"
}The catch keyword
You can use the catch keyword to specify a default value in case of failure.
node main(msg: string) {
// `result` is a Result type
const result = divide(10, 0)
// `result2` is a number. If `divide` is a failure,
// `result2` gets the default value of 3
const result2 = divide(10, 0) catch 3
}catch unwraps the Result type for you, and if it's a failure, uses the default value instead.
The pipe operator (|>)
If can be a pain to unwrap the Result type all the time. Here is a function called half, which works if the number is even:
def half(x: number): Result {
if (x % 2 != 0) {
return failure("Number must be even to be halved, got ${x}")
}
return divide(x, 2)
}Suppose I want to call half on some number three times. Unwrapping it each time as a pain:
let result = half(10)
if (isSuccess(result)) {
result = half(result.value)
if (isSuccess(result)) {
result = half(result.value)
return result
}
}
return "Error: ${result.error}"Use the pipe operator (|>) instead:
const result = success(10) |> half |> half |> halfPipe is a way to chain function calls together. The return value of one function is passed as the parameter to the next function. Pipe works with Result types, and it short-circuits on failures. So if the return value of a function is a success, pipe will unwrap it and pass it to the next function in the chain. But if it's a failure, it will short-circuit the chain and return that failure.
Printing the result variable, we see that it's an error, with the error message:
Number must be even to be halved, got 5The first call to half succeeded, but the second call failed, and so the pipe chain short-circuited and did not make the third call to half.
Pipes and PFA
You can use PFA on functions in a pipe chain:
const result = success([10, 20, 30]) |> map.partial(func: half)This only works if the resulting function has exactly one parameter left.
The try keyword
Agency has a try keyword. It is unrelated to catch. Even though Agency doesn't throw errors, you might call some TypeScript code that throws an error. The try keyword will catch the error and convert it to a failure for you:
// result is now a Result type
// if foo() throws an error, result will be a failure
const result = try foo()Agency also adds an automatic try-catch around every function definition, and if an error is thrown, it returns a Failure type.
Failure propagation
A failure is self-propagating. If you pass one to a function whose parameter is not typed to accept Results (Result, explicit any, or a union containing either), the function is skipped and the call returns the original failure, exactly like a pipe chain short-circuiting. Each skipped hop is recorded on the failure's skippedFunctions list, so the error you eventually inspect still points at the function that produced it. Passing a failure to an imported TypeScript function, or calling a method on a Result you forgot to unwrap, throws an error naming the producer instead.
def getReport(id: string): Result {
return failure("HTTP 404: report not found")
}
def wordCount(text: string): number {
return text.split(" ").length
}
node main() {
const report = getReport("abc") // oops: never checked
const count = wordCount(report) // wordCount is skipped
// count IS the original failure: count.error is the 404 message, and
// count.skippedFunctions is [{ name: "wordCount", param: "text" }].
}Result type parameters
The Result type has two type parameters: the success type and the failure's data type. The failure's message is always a string, so it is never named here. Both parameters default to any:
// success value is `any`, failure data is `any`
const result1: Result = divide(10, 0)
// success value is `number`, failure data is `any`
const result2: Result<number> = divide(10, 0)
// success value is `number`, failure data is a `ParseFailure`
const result3: Result<number, ParseFailure> = parseConfig("app.json")The second parameter must be an object type. Result<number, string> is a compile error, because no failure() call can produce string data.
A declared data type is required
If a function names a data type, every failure in it has to supply that data:
type ParseFailure = { line: number }
def loadConfig(path: string): Result<Config, ParseFailure> {
// compile error: this function promised ParseFailure data
return failure("No config at ${path}")
}Otherwise the type says the data is there when it is not, and a caller reading result.data.line gets null with nothing to explain why.
To allow both forms in one function, add null to the data type:
def loadConfig(path: string): Result<Config, ParseFailure | null> {
if (!exists(path)) {
return failure("No config at ${path}")
}
return failure("Bad syntax in ${path}", { line: 4 })
}At runtime .data is always an object, so result.data == null is never true. The null in ParseFailure | null says the producer may omit the data. To tell the two apart, test a field: if (result.data.line != null).