Error Handling
Floe replaces exceptions with Result<T, E> and replaces null checks with Option<T>. Every error path is visible in the type system.
Result
Section titled “Result”fn divide(a: number, b: number) -> Result<number, string> { match b { 0 -> Err("division by zero"), _ -> Ok(a / b), }}You must handle the result:
match divide(10, 3) { Ok(value) -> Console.log(value), Err(msg) -> Console.error(msg),}Ignoring a Result is a compile error:
// Error: Result must be handleddivide(10, 3)The ? Operator
Section titled “The ? Operator”Propagate errors early instead of nesting matches:
fn processOrder(id: string) -> Result<Receipt, Error> { const order = fetchOrder(id)? // returns Err early if it fails const payment = chargeCard(order)? // same here Ok(Receipt(order, payment))}The ? operator:
- On
Ok(value): unwraps tovalue - On
Err(e): returnsErr(e)from the enclosing function
Using ? outside a function that returns Result is a compile error.
The collect Block
Section titled “The collect Block”Normally, ? short-circuits on the first error. But sometimes you want all errors at once — form validation, batch processing, config parsing. The collect block changes ? from short-circuiting to accumulating:
fn validateForm(input: FormInput) -> Result<ValidForm, Array<ValidationError>> { collect { const name = input.name |> validateName? const email = input.email |> validateEmail? const age = input.age |> validateAge?
ValidForm(name, email, age) }}If the user submits name: "", email: "bad", age: -1, all three validators run and the caller gets Err([NameEmpty, InvalidEmail, AgeTooLow]) — not just the first failure.
How it works
Section titled “How it works”Inside collect {}:
- Each
?that hitsErrrecords the error and continues (instead of returning early) - Variables from failed
?get a zero value so subsequent lines can still run - If any failed, the block returns
Err(Array<E>)with all collected errors - If all succeeded, returns
Ok(last_expression)
The return type is always Result<T, Array<E>>.
collect vs regular ?
Section titled “collect vs regular ?”Regular ? | collect | |
|---|---|---|
| On first error | Returns immediately | Records error, continues |
| Return type | Result<T, E> | Result<T, Array<E>> |
| Best for | Sequential operations where later steps depend on earlier ones | Independent validations where you want all errors |
Use regular ? when operations are dependent (step 2 needs step 1’s result). Use collect when validations are independent and the user benefits from seeing everything wrong at once.
Real-world example: API config
Section titled “Real-world example: API config”type ApiConfig { baseUrl: string, apiKey: string, timeout: number,}
fn loadConfig(env: Env) -> Result<ApiConfig, Array<ConfigError>> { collect { const baseUrl = env |> requireEnv("API_BASE_URL")? const apiKey = env |> requireEnv("API_KEY")? const timeout = env |> requireEnv("TIMEOUT")? |> Number.parse?
ApiConfig(baseUrl, apiKey, timeout) }}// Err([Missing("API_KEY"), ParseError("TIMEOUT: not a number")])Mapping Error Types
Section titled “Mapping Error Types”When composing functions with different error types, use Result.mapErr to convert errors into a domain type. Variant constructors can be passed directly as functions:
type AppError { | Validation { errors: Array<string> } | Api { message: string }}
fn saveTodo(text: string, id: string) -> Result<Todo, AppError> { const todo = validateTodo(text, id) |> Result.mapErr(Validation)? const saved = apiSave(todo) |> Result.mapErr(Api)? Ok(saved)}Validation here is used as a function — equivalent to fn(e) Validation(errors: e). This works for any non-unit variant.
Option
Section titled “Option”fn findUser(id: string) -> Option<User> { match users |> find(.id == id) { Some(user) -> Some(user), None -> None, }}Handle with match:
match findUser("123") { Some(user) -> greet(user.name), None -> greet("stranger"),}npm Interop
Section titled “npm Interop”When importing from npm packages, Floe automatically wraps nullable types:
import { getElementById } from "some-dom-lib"// .d.ts says: getElementById(id: string): Element | null// Floe sees: getElementById(id: string): Option<Element>The boundary wrapping also converts:
T | undefinedtoOption<T>anytounknown
This means npm libraries work transparently with Floe’s type system.
todo and unreachable
Section titled “todo and unreachable”Floe provides two built-in expressions for common development patterns:
todo - Not Yet Implemented
Section titled “todo - Not Yet Implemented”Use todo as a placeholder in unfinished code. It type-checks as never, so it satisfies any return type. The compiler emits a warning to remind you to replace it.
fn processPayment(order: Order) -> Result<Receipt, Error> { todo // warning: placeholder that will panic at runtime}At runtime, todo throws Error("not implemented").
unreachable - Should Never Happen
Section titled “unreachable - Should Never Happen”Use unreachable to assert that a code path should never execute. Like todo, it has type never, but unlike todo, it does not emit a warning.
fn direction(key: string) -> string { match key { "w" -> "up", "s" -> "down", "a" -> "left", "d" -> "right", _ -> unreachable, }}At runtime, unreachable throws Error("unreachable").
When to Use Which
Section titled “When to Use Which”todo= “I haven’t written this yet” (development aid)unreachable= “This should never happen” (safety assertion)
For runtime type validation with parse<T>, see Type-Driven Features.
Comparison with TypeScript
Section titled “Comparison with TypeScript”| TypeScript | Floe |
|---|---|
T | null | Option<T> |
try/catch | Result<T, E> |
?. optional chain | match on Option |
! non-null assertion | Not available (handle the case) |
throw new Error() | Err(...) |