Error Handling
Kit uses algebraic data types for error handling instead of exceptions. This approach makes error cases explicit in function signatures and ensures they are handled at compile time.
Philosophy
Kit's error handling philosophy is based on making failure cases explicit and impossible to ignore. Rather than throwing exceptions that may or may not be caught, Kit functions return values that encode success or failure directly in the type system.
This approach has several benefits:
- Errors are visible in function signatures
- The compiler ensures all error cases are handled
- No hidden control flow from exceptions
- Errors can be composed and transformed like any other value
The Result Type
The Result type represents an operation that can succeed with a value or fail with
an error. It's defined as:
type Result a e = Ok a | Err e
Where a is the success type and e is the error type.
Creating Results
# Success case
success = Ok 42
# Error case
failure = Err "Something went wrong"
# Function that returns a Result
parse-int = fn(s) =>
match String.to-int s
| Some n -> Ok n
| None -> Err "Invalid integer: ${s}"
# Using the function
println (parse-int "42") # => Ok 42
println (parse-int "abc") # => Err "Invalid integer: abc"
Handling Results
# Pattern match to extract the value
result = parse-int "42"
match result
| Ok n -> println "Got number: ${n}"
| Err e -> println "Error: ${e}"
# Division that can fail
safe-divide = fn(a, b) =>
if b == 0 then
Err "Division by zero"
else
Ok (a / b)
match safe-divide 10 2
| Ok v -> println "Result: ${v}"
| Err e -> println "Error: ${e}"
The Option Type
The Option type represents a value that may or may not exist. It's useful when
absence of a value isn't necessarily an error. It's defined as:
type Option a = Some a | None
Creating Options
# Value present
has-value = Some 42
# Value absent
no-value = None
# Function returning Option
find-user = fn(users, id) =>
users
|>> filter (fn(u) => u.id == id)
|>> head-option
# List lookup
items = [1, 2, 3]
println (List.nth 0 items) # => Some 1
println (List.nth 10 items) # => None
Handling Options
# Pattern match to handle both cases
maybe-name = Map.get "name" config
match maybe-name
| Some name -> println "Hello, ${name}"
| None -> println "No name provided"
# Provide a default value
name = match Map.get "name" config
| Some n -> n
| None -> "Anonymous"
# Using Option.unwrap-or for defaults
name = config
|> Map.get "name"
|> Option.unwrap-or "Anonymous"
Pattern Matching Errors
Pattern matching is the primary way to handle Result and Option values.
The compiler ensures all cases are handled.
# File reading example
read-config = fn(path) =>
result = File.read path
match result
| Ok contents -> parse-config contents
| Err e -> Err "Failed to read config: ${e}"
# Multiple error types with nested matching
load-user-data = fn(id) =>
user-result = fetch-user id
match user-result
| Err e -> Err "User fetch failed: ${e}"
| Ok user ->
data-result = fetch-data user.data-id
match data-result
| Err e -> Err "Data fetch failed: ${e}"
| Ok data -> Ok {user: user, data: data}
# Matching with guards
categorize-result = fn(result) =>
match result
| Ok n if n > 100 -> "Large success"
| Ok n if n > 0 -> "Small success"
| Ok _ -> "Zero or negative"
| Err _ -> "Failed"
Chaining Operations
When multiple operations can fail, you can chain them together using combinators.
Result.map
Transform the success value while preserving errors:
# Transform success value
doubled = parse-int "21"
|> Result.map (fn(n) => n * 2)
# => Ok 42
# Error passes through unchanged
still-error = parse-int "abc"
|> Result.map (fn(n) => n * 2)
# => Err "Invalid integer: abc"
Result.and-then
Chain operations that can also fail:
# Chain multiple fallible operations
validate-and-double = fn(s) =>
parse-int s
|> Result.and-then (fn(n) =>
if n < 0 then
Err "Must be positive"
else
Ok (n * 2))
println (validate-and-double "21") # => Ok 42
println (validate-and-double "-5") # => Err "Must be positive"
println (validate-and-double "abc") # => Err "Invalid integer: abc"
Option combinators
# Option.map - transform the value if present
maybe-doubled = Some 21
|> Option.map (fn(n) => n * 2)
# => Some 42
# Option.and-then - chain optional operations
get-user-email = fn(users, id) =>
find-user users id
|> Option.and-then (fn(u) => u.email)
# Option.or-else - provide fallback Option
result = None
|> Option.or-else (fn() => Some "default")
# => Some "default"
Error Propagation
When building larger functions from smaller fallible operations, you need to propagate errors upward.
# Manual propagation with match
process-file = fn(path) =>
match File.read path
| Err e -> Err e
| Ok contents ->
match parse-json contents
| Err e -> Err e
| Ok data ->
match validate data
| Err e -> Err e
| Ok valid -> Ok (transform valid)
# Cleaner with and-then
process-file = fn(path) =>
File.read path
|> Result.and-then parse-json
|> Result.and-then validate
|> Result.map transform
# Adding context to errors
process-file = fn(path) =>
File.read path
|> Result.map-err (fn(e) => "Failed to read ${path}: ${e}")
|> Result.and-then (fn(contents) =>
parse-json contents
|> Result.map-err (fn(e) => "Invalid JSON: ${e}"))
|> Result.and-then (fn(data) =>
validate data
|> Result.map-err (fn(e) => "Validation failed: ${e}"))
|> Result.map transform
Converting Between Types
Sometimes you need to convert between Option and Result.
# Option to Result - provide error for None case
option-to-result = fn(opt, error) =>
match opt
| Some v -> Ok v
| None -> Err error
result = Map.get "name" config
|> option-to-result "name is required"
# Result to Option - discard error information
result-to-option = fn(result) =>
match result
| Ok v -> Some v
| Err _ -> None
# Or use Result.ok
maybe-value = some-result |> Result.ok
# Get error as Option
maybe-error = some-result |> Result.err
The Error Trait
Kit provides an Error trait that defines a common interface for error types.
This enables consistent error handling across packages while allowing domain-specific
error types with pattern matching.
Trait Definition
The Error trait requires Show and defines three methods:
trait Error a requires Show
message: a -> String # Human-readable error message
kind: a -> Keyword # Error category (default: :error)
code: a -> Option Int # Optional error code (default: None)
The kind and code methods have default implementations,
so you only need to implement message at minimum.
String Errors
For simple cases, String implements the Error trait,
so you can use strings directly as error values:
# Simple string errors work out of the box
validate = fn(x) =>
if x < 0 then
Err "value must be non-negative"
else
Ok x
# Access error message via the trait
match validate (-5)
| Ok v -> println "Got: ${v}"
| Err e -> println "Error: ${Error.message e}"
Custom Error Types
For complex domains, define algebraic data types for your errors and implement
the Error trait. This gives you type safety, pattern matching,
and a consistent interface.
Defining Error Types
# Define your error type with variants
type DbError =
| ConnectionFailed String
| QueryFailed String Int
| Timeout
# Implement Show (required by Error)
extend DbError with Show
show = fn(e) => match e
| ConnectionFailed msg -> "ConnectionFailed: ${msg}"
| QueryFailed msg code -> "QueryFailed(${code}): ${msg}"
| Timeout -> "Timeout"
# Implement Error trait
extend DbError with Error
message = fn(e) => match e
| ConnectionFailed msg -> msg
| QueryFailed msg _ -> msg
| Timeout -> "Database operation timed out"
kind = fn(e) => match e
| ConnectionFailed _ -> :connection-failed
| QueryFailed _ _ -> :query-failed
| Timeout -> :timeout
code = fn(e) => match e
| QueryFailed _ c -> Some c
| _ -> None
Using Custom Errors
# Function returning custom error type
connect = fn(host) =>
if host == "" then
Err (ConnectionFailed "empty host")
else
Ok { host: host, connected: true }
# Pattern match on specific error variants
match connect ""
| Ok conn -> println "Connected to ${conn.host}"
| Err (ConnectionFailed msg) -> println "Connection failed: ${msg}"
| Err (QueryFailed msg code) -> println "Query error ${code}: ${msg}"
| Err Timeout -> println "Operation timed out"
# Or use the Error trait for generic handling
match connect ""
| Ok conn -> println "Connected"
| Err e -> println "Error (${Error.kind e}): ${Error.message e}"
Standard Library Error Types
Kit's standard library provides typed errors for common operations. Import them
from the io module:
IOError
Used by file operations like File.read, File.write, etc.
type IOError =
| FileNotFound { path: String }
| AccessDenied { path: String }
| IsDirectory { path: String }
| NotDirectory { path: String }
| ReadError { message: String }
| WriteError { message: String }
| DeleteError { message: String }
| OutOfMemory
| IOError { message: String }
# Example: handling file read errors
match File.read "config.txt"
| Ok content -> process content
| Err (FileNotFound { path }) -> println "File not found: ${path}"
| Err (AccessDenied { path }) -> println "Access denied: ${path}"
| Err e -> println "Error: ${Error.message e}"
ParseError
Used by parsing functions like Int.parse, Float.parse, etc.
type ParseError =
| InvalidFormat { input: String }
| EmptyInput
| Overflow { input: String }
# Example: handling parse errors
match Int.parse "42"
| Ok n -> println "Parsed: ${n}"
| Err (InvalidFormat { input }) -> println "Invalid format: ${input}"
| Err EmptyInput -> println "Empty input"
| Err (Overflow { input }) -> println "Number too large: ${input}"
Best Practices
Use Option for absence, Result for errors
# Good: Option for "might not exist"
find-user = fn(id) => # returns Option User
# Good: Result for "might fail"
create-user = fn(data) => # returns Result User Error
Provide meaningful error messages
# Bad: generic error
Err "failed"
# Good: descriptive error with context
Err "Failed to parse config at line ${line}: ${reason}"
Use typed errors for complex domains
# Define error types for your domain
type UserError =
| NotFound String
| InvalidEmail String
| DuplicateUsername String
| PermissionDenied
create-user = fn(data) =>
if not (valid-email? data.email) then
Err (InvalidEmail data.email)
else if username-exists? data.username then
Err (DuplicateUsername data.username)
else
Ok (insert-user data)
Handle errors at appropriate levels
# Low-level: return errors
parse-config = fn(text) =>
# Returns Result Config ParseError
# Mid-level: transform and propagate
load-config = fn(path) =>
File.read path
|> Result.and-then parse-config
|> Result.map-err (fn(e) => "Config error: ${e}")
# Top-level: handle and respond
main = fn() =>
match load-config "app.toml"
| Ok config -> start-app config
| Err e ->
println "Error: ${e}"
exit 1
Next Steps
Now that you understand error handling, explore:
- Pattern Matching - Deep dive into pattern matching
- Result Module - All Result functions
- Option Module - All Option functions
- Modules - Organize your code