Skip to content

Option and Result, the boxed ones

?T remains the allocation-free choice for everyday absence. When nesting, combinators, a stable serialized tag, or a visible recoverable failure matters, import Hem’s canonical boxed carriers:

import { Option } from "@hem-lang/std/option"
import { Result } from "@hem-lang/std/result"

let port: Option<number> = Some(8080)
let parsed: Result<number, string> = Ok(8080)

The checked type gives Some/None and Ok/Err their representation. Without that context, write Option.some(8080) or Result.ok(8080) so inference starts from Option<number> or Result<number, never>. Both values are transparent readonly objects with _tag and .value, and both are must-use even in a project that never enables Effect. let _ = operation() is the one deliberate discard.

The APIs are synchronous, fixed-arity, and data-first. That makes direct and piped code one call:

let normalized = port |> Option.map((value) => value + 1)
let recovered = parsed |> Result.getOr(3000)

The most important operation is syntax rather than a generator helper. An omitted-else success binding returns the original residual from the nearest arrow:

let decodePort = (text: string): Result<number, ParseError> => {
  let Ok(value) = parsePort(text)
  Result.ok(value)
}

let loadPort = async (): Promise<Result<number, LoadError | ParseError>> => {
  let Ok(text) = await readPortText()
  let Ok(port) = parsePort(text)
  Result.ok(port)
}

The RHS runs once. Err is returned unchanged; Some similarly propagates boxed None, while a native ?T binding preserves the exact observed null or undefined. The destination must use the same representation, callbacks form their own boundary, and Promise rejection stays rejection. Adding else always means ordinary local recovery. Inside effect {} the block itself is the boundary: the residual returns unchanged on the Effect success channel, and only postfix ! moves a failure into the Effect failure channel.

Carrier bindings also group with and. Every member evaluates to completion — sequentially when pure, under one Promise.all when awaited, under one Effect.all when bang-marked — and then the members are checked in the order you wrote them; the first residual returns unchanged:

let loadPair = async (): Promise<Result<Pair, LoadError>> => {
  let Ok(left) = await readLeft() and Ok(right) = await readRight()
  Result.ok(Pair(left, right))
}

Effect remains a separate lazy transport, using postfix ! to bridge carriers into effects:

let program = effect {
  let port = parsePort(rawPort)!
  let token = Option.fromNullish(env.TOKEN)!
  connect(port, token)!
}

Canonical Err<E> enters the Effect error channel as that exact E; canonical None becomes the transport’s checked NoSuchElementError. Native ?T! is refused because nullish absence carries no error policy. Effect 4’s own eager carriers are Option and Result<A, E> (spelled Result.Result through its namespace; the pre-release bare Result alias is gone). Postfix ! on them lowers to Effect.fromOption/Effect.fromResult, and checked Effect Result values still construct and match with Ok and Err.

This provides a consistent model: focused package imports at the boundary, ordinary sync and async carrier propagation in domain code, and one explicit adapter where the program enters Effect. The carrier reference lists every operation, the frozen ABI, and third-party interoperability rules.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close