Skip to content

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.

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 handled
divide(10, 3)

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 to value
  • On Err(e): returns Err(e) from the enclosing function

Using ? outside a function that returns Result is a compile error.

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.

Inside collect {}:

  • Each ? that hits Err records 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>>.

Regular ?collect
On first errorReturns immediatelyRecords error, continues
Return typeResult<T, E>Result<T, Array<E>>
Best forSequential operations where later steps depend on earlier onesIndependent 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.

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")])

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.

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"),
}

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 | undefined to Option<T>
  • any to unknown

This means npm libraries work transparently with Floe’s type system.

Floe provides two built-in expressions for common development patterns:

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").

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").

  • 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.

TypeScriptFloe
T | nullOption<T>
try/catchResult<T, E>
?. optional chainmatch on Option
! non-null assertionNot available (handle the case)
throw new Error()Err(...)