Skip to content

Errors and failures

Core errors, raise, postfix catch, and try; which failures types track; the same vocabulary under Effect.

Updated View as Markdown

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:

  • raise puts a declared error on the typed E channel.
  • catch handles the error channel of an Effect-valued expression.
  • await directly in an effect block lifts a promise through Effect.tryPromise; catch can translate its rejection by raising a domain error. Inside an async arrow nested in the block, await is native JavaScript await.
  • throw remains a defect.
  • These meanings stop at a function boundary. In an arrow nested inside an effect block, raise is the core throw, so an uncaught one reaches the block as a defect rather than a typed failure; a nested effect block 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.

Normative source

Specification 0.2, §9 (core errors and exceptions), §7 (patterns and control flow), and §14 (Effect transport).

Navigation

Type to search…

↑↓ navigate↵ selectEsc close