Hem provides error handling in the zero-dependency core. error declares a nominal
error class, raise throws one, and postfix catch handles selected failures. The Effect
extension uses the same syntax but changes how failures propagate and what the types record: core
code throws through the JavaScript stack, while an effect {} block fails a typed error channel.
What the types track
In core, error values are typed. An error declaration is a class, so a raised instance has a
precise type and a catch arm matches it by tag. Raising is not typed. A core raise is a
JavaScript exception: the enclosing function’s type does not mention it, callers’ types do not
inherit it, and no caller is required to handle it.
error Boom({ why: string })
export let risky = (n: number) => {
if n > 3 { raise Boom({ why: "big" }) }
n * 2
}
export let caller = (n: number): number => risky(n)class Boom extends Error {
readonly _tag = "Boom";
readonly why: string;
constructor(fields: { readonly why: string }) {
super("Boom");
this.name = "Boom";
this.why = fields.why;
}
}
export const risky = (n: number) => {
if (n > 3) {
throw new Boom({ why: "big" });
}
return n * 2;
};
export const caller = (n: number): number => risky(n);Both risky and caller have the type (n: number) => number. Nothing in either signature
records that Boom can escape, and caller compiles without handling it.
When a failure must be visible in a signature, choose a mechanism that records it:
| Mechanism | Where the failure appears | What a caller must do |
|---|---|---|
Core raise |
Nowhere in the function type | Nothing; an unhandled error propagates at run time |
Result<T, E> from @hem-lang/std/result |
The return type’s E |
Inspect the must-use result, or propagate it with let Ok(x) = ... |
raise in an effect {} block |
The Effect’s error channel E |
Handle it with catch, or leave it in the caller’s E |
Returning a canonical Result keeps core code free of Effect while making the failure part of
the contract:
import { Result } from "@hem-lang/std/result"
error Boom({ why: string })
export let risky = (n: number): Result<number, Boom> =>
if n > 3 { Result.err(new Boom({ why: "big" })) } else { Result.ok(n * 2) }
export let caller = (n: number): Result<number, Boom> => {
let Ok(doubled) = risky(n)
Result.ok(doubled + 1)
}let Ok(doubled) without else returns the original Err from caller, so the error union
stays in caller’s return type. See standard Option and Result
for carrier binding and the Result API, and Effect boundaries for
the error channel.
Error declarations
error Name declares a tagged error class. A record payload adds its fields to the class;
the class also has a readonly _tag and extends Error. Declared cause and message fields
are forwarded to the Error constructor. export error exports the class.
error MissingUser({ id: string })
let findUser = (id: string): string => {
if id == "" { raise MissingUser({ id }) }
id
}In core output, raising a local error with a record payload automatically inserts new. Imported
error classes cross TypeScript boundaries, so construct them explicitly with new in both
core and Effect code. Under the Effect extension, the same declaration emits a
Data.TaggedError; both forms extend Error and carry _tag, so code compiled in either
mode can match the other. See variants, brands, and errors for
the declaration shape.
raise
raise e terminates execution at the point where it appears. In core it compiles to a throw,
which no type records. In an Effect block it compiles to yield* Effect.fail(e), adding e to
the block’s checked error channel. Because it
diverges, raise is valid in a guard branch and does not need to produce the surrounding value’s
type.
In both worlds Hem constructs locally declared errors for you: raise MissingUser({ id }) emits
new MissingUser({ id }), and a payload-free error is raised by name, so raise DecodeFailed
emits new DecodeFailed(). Imported error classes take an explicit raise new Name(…). Raising a
bare constructor instead of an instance (a local error with fields named without them, or an
imported class without new) triggers HEM2034.
throw remains standard JavaScript for defects and rethrows. In an Effect
block, a throw is not a typed failure and is not handled by an Effect.catch arm.
Postfix catch
catch has the lowest operator precedence, attaching to the complete expression on its left, including an
await expression. Its arms are the same in core and Effect:
| Arm | What it handles |
|---|---|
Name(_) => value |
A named error, discarding its instance |
Name(error) => value |
A named error, binding its instance |
Name({ field }) => value |
A named error, destructuring its fields |
Name({ field }) as error => value |
Both the fields and the error instance |
error => value |
Any failure; error is unknown in core |
Named arms use instanceof in core. A binding arm is the catch-all arm. Handle only the failures you
intend to recover: an unmatched failure propagates. Core rethrows it; Effect leaves it in the
remaining error channel. HEM2011 reports a tag that is not an error class in core or is absent
from the Effect error channel. If TypeScript exposes any for the caught expression, Hem may
be unable to check a declared error arm against either channel. Give the expression a known
type, such as Effect<A, Offline>, so Hem can check what the catch handles.
In core, the compiler does not check a handler against what its subject can throw, because no type records that; under Effect, arms are checked against the inferred error channel.
Synchronous code
The handler is an expression, so its result becomes the result of the whole postfix expression. This uses standard stack-based exception handling in core.
error ParseFailed({ input: string })
let parse = (input: string): string => {
if input == "" { raise ParseFailed({ input }) }
input
}
let parseOrDefault = (input: string): string =>
parse(input) catch {
| ParseFailed(_) => "default"
}The ParseFailed arm handles only that class. If parse throws another error, it leaves this
handler and propagates up the caller’s stack.
Async code
Await before handling a promise rejection. A handler after a bare promise observes promise construction, not its eventual rejection; Hem reports a hint for that form.
error GatewayFailed({ url: string })
let request = async (url: string): Promise<string> => url
let load = async (url: string): Promise<string> =>
await request(url) catch {
| _ => raise GatewayFailed({ url })
}In a standard async arrow, this compiles to core try/catch around the awaited operation. In an
effect {} block, the same await ... catch handles the rejection at the Effect boundary; an
uncaught rejection becomes the configured library’s Cause.UnknownError.
try for protected regions
Postfix catch is the compact form. try { ... } catch pattern { ... } is the expression
form for a protected region containing several statements. The optional binding is a pattern; a
plain binding has type unknown and must be narrowed before use. Both the protected block and the
handler block can return a value. Unmatched failures propagate.
error DecodeFailed
let decode = (input: string): string =>
try {
if input == "" { raise DecodeFailed }
input
} catch problem {
if problem instanceof DecodeFailed { "default" } else { throw problem }
}try produces a value even when it contains multiple statements. In statement position, it
runs for its effects. The value-position lowering rules, including where a nested protected
region can be hoisted, are in expressions and control flow.
Effect continuity
Effect is opt-in through effectImportSource; without it, these constructs introduce no runtime
imports. With it, the syntax remains unchanged:
raiseputs a declared error on the typedEchannel.catchhandles the error channel of an Effect-valued expression.awaitdirectly in aneffectblock lifts a promise throughEffect.tryPromise;catchcan translate its rejection by raising a domain error. Inside anasyncarrow nested in the block,awaitis native JavaScriptawait.throwremains a defect.- These meanings stop at a function boundary. In an arrow nested inside an
effectblock,raiseis the corethrow, so an uncaught one reaches the block as a defect rather than a typed failure; a nestedeffectblock is typed again.
An Effect catch handler’s pure result is lifted into a successful Effect, an Effect result passes
through, and an effect {} handler body becomes a nested Effect block. A named arm narrows the
error channel; _ handles the typed failure channel, not defects. Resource cleanup runs before a
handler executes, so a catch arm never observes a live scoped resource. The Effect boundaries
reference documents the channel, scope, and execution-edge rules.
Related rules
- Absence (
?T) covers missing values, which are not thrown failures. - Standard Option and Result covers explicit eager carriers and their separate Effect adapter.
- Pattern matching covers the patterns shared by
match,let, andcatch.
Normative source
Specification 0.2, §9 (core errors and exceptions), §7 (patterns and control flow), and §14 (Effect transport).