Skip to content

Handlers

A handler implements one endpoint. You write handlers with HttpApiBuilder.group, which gives you a builder whose .handle(name, fn) method is type-checked against the API definition: the endpoint name must exist, and the handler’s inputs and outputs must match the endpoint’s schemas. The result is a Layer that provides that group’s routes.

Keep request handling thin: put the real work in a service so it stays testable and reusable. Note the use of Effect.fn for methods that return Effects, and a single wrapper error (UsersError) so the service’s failure channel stays small.

server/Users.ts
import { Context, Effect, Layer, Ref } from "effect"
import { User, UserId } from "../domain/User.ts"
import { SearchQueryTooShort, UserNotFound, UsersError } from "../domain/UserErrors.ts"
export class Users extends Context.Service<Users, {
list(search: string | undefined): Effect.Effect<Array<User>, UsersError>
getById(id: UserId): Effect.Effect<User, UsersError>
create(input: { readonly name: string; readonly email: string }): Effect.Effect<User, UsersError>
}>()("acme/Users") {
static readonly layer = Layer.effect(
Users,
Effect.gen(function*() {
const users = new Map<number, User>()
const nextId = yield* Ref.make(1)
const list = Effect.fn("Users.list")(function*(search: string | undefined) {
const all = Array.from(users.values())
if (search === undefined || search.length === 0) return all
if (search.length < SearchQueryTooShort.minimumLength) {
// Wrap domain errors in the single UsersError reason union
return yield* new UsersError({ reason: new SearchQueryTooShort() })
}
const q = search.toLowerCase()
return all.filter((u) => u.name.toLowerCase().includes(q))
})
const getById = Effect.fn("Users.getById")(function*(id: UserId) {
const user = users.get(id)
if (user === undefined) {
return yield* new UsersError({ reason: new UserNotFound() })
}
return user
})
const create = Effect.fn("Users.create")(function*(input: { readonly name: string; readonly email: string }) {
const id = yield* Ref.getAndUpdate(nextId, (n) => n + 1)
const user = new User({ id: UserId.make(id), ...input })
users.set(user.id, user)
return user
})
return Users.of({ list, getById, create })
})
)
}

HttpApiBuilder.group(api, groupName, build) builds handlers for one group. The build function runs as an Effect — yield* your dependencies, then return the chained .handle(...) calls. Each handler receives the decoded inputs (params, query, payload, headers) and returns an Effect producing the endpoint’s success type.

server/Users/http.ts
import { Effect, Layer } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { Api } from "../../api/Api.ts"
import { CurrentUser } from "../../api/Authorization.ts"
import { Users } from "../Users.ts"
export const UsersApiHandlers = HttpApiBuilder.group(
Api,
"users",
Effect.fn(function*(handlers) {
// Resolve dependencies once for the whole group
const users = yield* Users
return handlers
.handle("list", ({ query }) =>
users.list(query.search).pipe(
// The `list` endpoint declares no errors, so any failure here must be
// turned into an unexpected defect (becomes a 500).
Effect.orDie
))
.handle("getById", ({ params }) =>
// `params.id` is already decoded to a branded UserId
users.getById(params.id).pipe(
// Surface only the UserNotFound reason as a typed failure; treat the
// rest as defects.
Effect.catchReasons("UsersError", {
UserNotFound: (e) => Effect.fail(e)
}, Effect.die)
))
.handle("create", ({ payload }) =>
users.create(payload).pipe(Effect.orDie))
.handle(
"search",
Effect.fn(function*({ payload }) {
// Built-in HttpApiError types behave like any tagged error
if (payload.search === "bad-request") {
return yield* new HttpApiError.RequestTimeout()
}
return yield* users.list(payload.search).pipe(
// Re-fail one reason, die on the others
Effect.catchReason(
"UsersError",
"SearchQueryTooShort",
Effect.fail,
Effect.die
)
)
})
)
// Endpoints with no inputs receive an empty object. This handler simply
// returns a service provided by middleware (see the auth page).
.handle("me", () => CurrentUser)
})
).pipe(
// Provide everything the handlers need
Layer.provide(Users.layer)
)

A handler may only fail with errors the endpoint declared. Anything else must become a defect (an unexpected 500). The combinators that bridge your service errors to the endpoint’s error channel are the standard ones from Error Management:

  • Effect.orDie — the endpoint declares no errors; convert any failure to a defect.
  • Effect.catch — the catch-all handler (the v4 replacement for catchAll).
  • Effect.catchTag / Effect.catchTags — handle specific tagged errors.
  • Effect.catchReason / Effect.catchReasons — handle reasons inside a wrapper error like UsersError without unwrapping it.

If you prefer to flatten a wrapper error’s reasons up to the top level so you can use catchTag/catchTags, use Effect.unwrapReason:

import { Effect } from "effect"
users.create(payload).pipe(
// Lift UsersError's reasons (UserNotFound | SearchQueryTooShort) to the top
Effect.unwrapReason("UsersError"),
Effect.catchTags({
UserNotFound: Effect.die,
SearchQueryTooShort: Effect.die
})
)

HttpApiBuilder.group returns a Layer. You provide all your group layers to HttpApiBuilder.layer(Api) when assembling the server — covered next in Serving & clients. A trivial group can be implemented inline:

import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
export const SystemApiHandlers = HttpApiBuilder.group(
Api,
"system",
Effect.fn(function*(handlers) {
return handlers.handle("health", () => Effect.void)
})
)

To secure these endpoints and inject the current user, continue to Middleware & auth.