Skip to content

effect { } — a description, not an action

An effect { } block is Hem’s one lazy construct: it evaluates to a description of work. Nothing runs until an Effect.run* at the edge of your program: bind a block to a name and use it twice, it runs twice.

users.hemhem
error UserNotFound({ id: string })

service Users {
  findById: (id: string) => Effect<?UserRow, DbError>
}

export let getUser = (id: string) => effect {
  let users = Users!
  let row = users.findById(id)!
  let Some(user) = row else raise UserNotFound({ id })
  user
}
// tsc infers: (id: string) => Effect<UserRow, DbError | UserNotFound, Users>
users.ts — generatedts
import { Context, Data, Effect } from "effect";

class UserNotFound extends Data.TaggedError("UserNotFound")<{ readonly id: string }> {}

class Users extends Context.Service<
  Users,
  {
    readonly findById: (id: string) => Effect.Effect<UserRow | null | undefined, DbError>;
  }
>()("src/domain/users#Users") {}

export const getUser = (id: string) =>
  Effect.gen(function* () {
    const users = yield* Users;
    const row = yield* users.findById(id);
    const _hemValue = row;
    if (_hemValue === null || _hemValue === undefined) {
      return yield* Effect.fail(new UserNotFound({ id }));
    }
    const user = _hemValue;
    return user;
  });

A bare effect statement is refused (HEM2018): users.findById(id) with no ! inside a block describes work and then drops the description, so the message points at the ! you meant. Run it with !, or (when you truly mean to ignore the result) write let _ = users.findById(id)!.

Inside a block, three exits mean three things: return completes the block early with a success value (the block, not the enclosing function; a block is its own generator), raise fails the typed error channel, and throw is a defect: an unrecoverable bug, per Effect’s semantics. raise diverges, so it slots into let-else as the guard idiom above. And await changes transport inside a block: it lifts a promise-typed expression through Effect.tryPromise, thunked so retries re-execute it. Like !, these block meanings stop at a function boundary: inside an arrow nested in the block, await is plain JavaScript await (so an Effect.tryPromise(async () => ...) callback reads as written) and raise is the core throw, which reaches the block as a defect if nothing catches it. Uncaught rejections surface as the library’s Cause.UnknownError; catch is the same handler you met in §11: arms bind the raw thrown value, recover, and anything unmatched travels on. Translating into your typed domain is just an arm that raises:

let res  = await fetch(url) catch { _ => raise NetworkDown({ url }) }
let data = await res.json() catch { _ => raise BadPayload({ status: res.status }) }

Bare promises never bind on their own (the lift is always spelled), and abort propagation to cancellable APIs uses the explicit Effect.tryPromise((signal) => fetch(url, { signal }))! form for now.

That ownership rule also applies when calling Effect directly. Effect.succeed(Promise.resolve(value)) is HEM4115: the effect succeeds immediately with a Promise value, so Effect cannot await, retry, interrupt, or translate its rejection. Use await in an Effect block, or construct the operation with Effect.tryPromise.

Hem also rejects duplicate effect installations, generic service tags whose erased type arguments would collide at runtime, and Effects carried as success or failure data. These are Hem’s checked application boundaries, not a claim that every refused shape is invalid TypeScript. The Effect boundaries reference explains them and the probe contract.

! is the bind operator. It yields anything Effect.gen accepts and is postfix-tight, so piping before binding requires parentheses: (getUser(id) |> Effect.timeout("5 seconds"))!. The compiler enforces that ! cannot bind across function boundaries:

error[HEM4103] sync.hem:12:47
   |
12 | let results = ids.map((id) => fetchUser(id)!)
   |                                             ^ Postfix `!` cannot bind through an arrow or function boundary. Return the effect from the callback and bind the collection operation with `Effect.forEach`.

Name the operations that deserve tracing explicitly:

users.hemhem
export let fetchUser = effect traced (id: UserId): Effect<User, UserNotFound, Users> => {
  let users = Users!
  users.findById(id)!
}
users.ts — generatedts
export const fetchUser = Effect.fn("src/domain/users#fetchUser")(
  function* (id: UserId): Effect.fn.Return<User, UserNotFound, Users> {
    const users = yield* Users;
    return yield* users.findById(id);
  },
);

Hem stays arrow-only: there is no effect fn. The effect traced prefix requires one immutable named binding with a block body. Local operations may capture dependencies; their trace names include enclosing named bindings, with a source-location suffix for collisions. Defining an operation does not execute it or extend the lifetime of captured services. Cleanup belongs to the operation’s execution. The ordinary (id) => effect { ... } form remains untraced and keeps its existing Effect.gen emit.

let makeLookup = (prefix: string) => {
  let lookup = effect traced (id: string): Effect<string> => {
    `${prefix}${id}`
  }
  lookup
}
let lookup = makeLookup("user:")
let program = effect { lookup("42")! }
Navigation

Type to search…

↑↓ navigate↵ selectEsc close