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.

Sometimes you want to validate multiple things and collect all errors, not just the first one. 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)
}
}

Inside collect {}:

  • Each ? that hits Err records the error and continues
  • If any failed, the block returns Err(Array<E>) with all collected errors
  • If all succeeded, returns Ok(last_expression)

The return type of a collect block is always Result<T, Array<E>>.

This is useful for form validation, batch processing, and anywhere you want to report all errors at once instead of stopping at the first one.

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)

The parse<T> built-in validates unknown data against a type at runtime. The compiler generates the validation code - no runtime library needed.

// Validate JSON data against a type
const user = json |> parse<User>?
// With inline record types
const point = data |> parse<{ x: number, y: number }>?
// Validate arrays
const items = raw |> parse<Array<Product>>?

parse<T> returns Result<T, Error>. Use ? to unwrap or match to handle errors:

match data |> parse<User> {
Ok(user) -> Console.log(user.name),
Err(e) -> Console.error(e.message),
}

Supported types: string, number, boolean, record types, Array<T>, Option<T>, and named types.

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