Skip to content

Functions & Let

All bindings are immutable. Use let:

let name = "Floe"
let count = 42
let active = true

With type annotations:

let name: string = "Floe"
let count: number = 42
let (left, right) = getPair() // `()` — tuple
let { name, age } = getUser() // `{}` — record

Array destructuring (let [a, b] = arr) is a parse error in let bindings — it would lie about runtime length. Use Array.get(arr, i) -> Option<T> or a match pattern:

match arr {
[first, ..rest] -> todo,
_ -> todo,
}
let add(a: number, b: number) -> number = {
a + b
}

The last expression in a function body is the return value. The return keyword is not used in Floe.

In multi-statement functions, floe fmt adds a blank line before the final expression to visually separate the return value:

let loadProfile(id: string) -> Result<Profile, ApiError> = {
let user = fetchUser(id)?
let posts = fetchPosts(user.id)?
let stats = computeStats(posts)
Profile(user, posts, stats)
}

Exported functions must have return type annotations:

export let greet(name: string) -> string = {
`Hello, ${name}!`
}

Functions can declare type parameters using angle brackets after the function name:

let identity<T>(x: T) -> T = { x }
let pair<A, B>(a: A, b: B) -> (A, B) = { (a, b) }
let mapResult<T, U, E>(r: Result<T, E>, f: (T) -> U) -> Result<U, E> = {
match r {
Ok(value) -> Ok(f(value)),
Err(e) -> Err(e),
}
}

Generic functions compile directly to TypeScript generics:

function identity<T>(x: T): T { return x; }
function pair<A, B>(a: A, b: B): readonly [A, B] { return [a, b] as const; }
let greet(name: string = "world") -> string = {
`Hello, ${name}!`
}

Use (x) -> expr for inline anonymous functions:

todos |> Array.map((t) -> t.text)
items |> Array.reduce((acc, x) -> acc + x.price, 0)

For simple field access, use dot shorthand:

todos |> Array.filter(.done == false)
todos |> Array.map(.text)
users |> Array.sortBy(.name)

Floe accepts two forms for binding a function to a name. Both compile to the same TypeScript — pick the one that reads better for the situation.

Def-form — params and return type sit inline on the let:

let add(a: number, b: number) -> number = a + b

Value-binding form — function expression on the right of =:

// Plain value binding
let handleClick = () -> setCount(count + 1)
// With a type annotation on the let — useful when the type is named
typealias Handler = (Request) -> Promise<Response>
let placeOrder: Handler = (req) -> req |> validate? |> price |> Ok

When to pick which:

  • Def-form for top-level functions with several parameters or a meaningful return type — params and return read together as a signature, no closure noise.
  • Value-binding form when the function implements a named type alias (the type lives once on the let, not duplicated in the parameters) or when the body is a one-liner that already reads well as an expression.

Exported functions must have a return type annotation regardless of which form you use.

Use -> to describe function types:

typealias Transform = (string) -> number
typealias Predicate = (Todo) -> boolean
typealias Callback = () -> ()

A function is async when its body uses |> await (or |> Promise.await). The return type must be Promise<T> — the compiler enforces this, just like ? requires Result<T, E>:

let fetchUser(id: string) -> Promise<User> = {
let response = fetch(`/api/users/${id}`) |> await
response.json() |> await
}

For functions without a return type annotation, the compiler infers Promise<T> automatically.

async let sugar. When the return type is verbose (e.g. Promise<Result<Option<T>, Error>>), use async let f() -> T = { ... } to write the inner type directly. The compiler wraps it in Promise<>:

// Verbose
let findUser(id: string) -> Promise<Result<Option<User>, Error>> = {
// ...
}
// Sugar — the Promise<> wrapper is implied
async let findUser(id: string) -> Result<Option<User>, Error> = {
// ...
}

Both forms compile to the same async function in TypeScript. Callers still use |> await to unwrap. See the Promise reference for details.

The use keyword flattens nested callbacks. The rest of the block becomes the callback body:

// Without use — deeply nested
File.open(path, (file) ->
File.readAll(file, (contents) ->
contents |> String.toUpper
)
)
// With use — flat and readable
use file <- File.open(path)
use contents <- File.readAll(file)
contents |> String.toUpper

Zero-binding form for callbacks that don’t pass a value:

use <- Timer.delay(1000)
Console.log("step 1")
use <- Timer.delay(500)
Console.log("done")

use works with any function whose last parameter is a callback. It’s complementary to ? (which only works on Result/Option).

  • No const or var - all bindings use let and are always immutable
  • No class - use functions and records
  • No this - functions are pure by default
  • No function* generators - use arrays and pipes
  • No => anywhere in Floe source-> covers closures, match arms, return types, and function types. => only appears in emitted TypeScript.
  • No function keyword — use let for named functions

These are removed intentionally. See the introduction for the reasoning.