better-effect
Integrations and adapters

Hono

Execute better-effect Programs inside one Hono request boundary.

The optional better-effect/hono entry point connects Hono to a long-lived Runtime. Hono's web Request and Response values do not extend the published Runtime entrypoint's official support beyond Node.js and Bun. Install Hono alongside the two core packages:

For an upstream HTTP client stream, see the HTTP client guide before using the managed routes.stream boundary below. It documents the request Scope lifetime, downstream cancellation, and the difference between buffered and streaming responses.

bun add better-effect better-result hono

The adapter does not resolve Services into the Hono Context and does not wrap individual methods with a Proxy. It keeps Hono's middleware, validator, and Context behavior local, then hands the standard Web Request to the shared better-effect/web boundary. That boundary owns the single Runtime execution, Result/defect policy, and request Scope cleanup for the whole request.

Configure the request boundary

Declare the Hono application as a Layer and compose it into the Runtime once during application startup:

import { Hono } from 'hono'
import { Result } from 'better-result'
import { Effect, Layer, Runtime, Service } from 'better-effect'
import { HonoEffect } from 'better-effect/hono'

class WorkOrderService extends Service<WorkOrderService>()('WorkOrderService') {
  list() {
    return Promise.resolve(Result.ok([{ id: 'wo-1' }]))
  }
}

const AppLive = Layer.make(WorkOrderService)
const App = HonoEffect.app(
  '@app/HonoApp',
  {
    onFailure: (_error, c) => c.json({ error: 'Request failed' }, 500)
  },
  async function* (http) {
    const app = new Hono()

    app.use('/api/*', yield* http.middleware())
    app.get(
      '/api/work-orders',
      yield* http.gen(async function* () {
        const workOrders = yield* WorkOrderService
        const items = yield* Result.await(workOrders.list())

        return Result.ok(items)
      })
    )

    return app
  }
)

const runtime = await Runtime.make(Layer.merge(AppLive, App.layer))
const appResult = await runtime.run(
  Effect.fn(async function* () {
    return Result.ok(yield* App)
  })
)
if (Result.isError(appResult)) throw new Error(String(appResult.error))
const app = appResult.value

await runtime.dispose()

The remaining route examples below are the contents of the HonoEffect.app factory, after its const app = new Hono() declaration. For an existing application Service, use HonoEffect.layer(ServiceToken, options, factory) and compose the returned Layer in the same way.

The request boundary supplies CurrentRequest and links the request's AbortSignal to the Runtime execution. Services are resolved lazily through yield* Service, and resources acquired with Effect.acquireRelease belong to that request's Scope. Registering the middleware more than once for a request still creates only one execution. The default failure policy redacts every non-Response failure value to { error: 'Internal Server Error' } with status 500, including non-Error values. An explicitly returned Response failure is intentionally passed through unchanged. A custom onFailure should expose only safe, intentional domain details.

Registering the same boundary more than once for a request does not create a second execution. Middleware registered before http.middleware() runs outside the boundary; place the boundary before any middleware that needs better-effect Services or request-local resources.

Programs and generators

Use http.gen for a generator written at the route boundary:

app.get(
  '/api/work-orders/:id',
  http.gen(async function* (c) {
    const workOrders = yield* WorkOrderService
    const workOrder = yield* Result.await(workOrders.get(c.req.param('id')))

    return Result.ok(workOrder)
  })
)

Use http.handler when the Program is defined outside the HTTP layer:

import { Result } from 'better-result'
import { Effect } from 'better-effect'

const getWorkOrder = (id: string) =>
  Effect.fn(async function* () {
    const workOrders = yield* WorkOrderService
    const workOrder = yield* Result.await(workOrders.get(id))

    return Result.ok(workOrder)
  })

app.get(
  '/api/work-orders/:id',
  http.handler((c) => getWorkOrder(c.req.param('id')))
)

Both APIs validate the Program's Service requirements against the application Layer. A route that yields a Service absent from AppLive fails at compile time; no manual Service catalog is needed. The Failure type passed to onFailure and the RequestLayer returned by requestLayer become Layer requirements, so incompatible failure handlers or request Layers are rejected at the public boundary. Request-Layer external requirements must be provided by the Runtime.

Hono validation and Standard Schema

http.gen and http.handler accept one or more Hono input middlewares before the callback. They run in the order provided, and each inferred value from c.req.valid(...) is preserved in the route Context. There is no JsonInput<T> helper to pass manually.

For a Standard Schema-compatible validator, use Hono's Standard Schema adapter:

bun add @hono/standard-validator
import { sValidator } from '@hono/standard-validator'

const validateCreateWorkOrder = sValidator('json', createWorkOrderSchema)

app.post(
  '/api/work-orders',
  http.gen(
    validateCreateWorkOrder,
    async function* (c) {
      const input = c.req.valid('json')
      const workOrders = yield* WorkOrderService
      const workOrder = yield* Result.await(workOrders.create(input))

      return Result.ok(workOrder)
    },
    { status: 201 }
  )
)

The same overload works with http.handler:

app.post(
  '/api/work-orders',
  http.handler(validateCreateWorkOrder, (c) => {
    const input = c.req.valid('json')

    return createWorkOrder(input)
  })
)

List multiple validators before the callback when a route needs several input categories:

app.post(
  '/api/work-orders/:id',
  http.gen(validateParam, validateHeader, validateCreateWorkOrder, async function* (c) {
    const id = c.req.valid('param').id
    const key = c.req.valid('header')['X-Idempotency-Key']
    const input = c.req.valid('json')

    return Result.ok({ id, key, input })
  })
)

Validation failures are handled by the validator middleware and return its Response before the Program runs. The adapter only connects the validated Hono input to the typed Program.

Responses and serialization

The default success policy returns { data: value } as JSON. Use route options to set a status or serialize a domain value before it is returned:

app.get(
  '/api/work-orders/:id',
  http.gen(
    async function* (c) {
      const workOrders = yield* WorkOrderService
      return Result.ok(yield* Result.await(workOrders.get(c.req.param('id'))))
    },
    {
      serialize: (workOrder) => ({
        id: workOrder.id,
        createdAt: workOrder.createdAt.toISOString()
      })
    }
  )
)

The serialized value is returned as { data: serializedValue }. A successful Response bypasses the default policy. For complete control, use respond on the route or provide a global onSuccess policy:

app.get(
  '/download',
  http.gen(async function* () {
    return Result.ok(new Response('file contents'))
  })
)

app.get(
  '/api/summary',
  http.handler(() => summaryProgram, {
    respond: (summary, c) => c.json(summary, 200)
  })
)

Result.err(error) is passed to onFailure and converted to the configured HTTP response. The original error is also retained as the request Scope's failure outcome, so finalizers and Runtime observers see the typed cause. Under the default policy, every non-Response failure value is redacted to { error: 'Internal Server Error' } with status 500. An explicitly returned Response failure is intentionally passed through unchanged. Custom policies are responsible for deliberately choosing any safe domain details to expose. Thrown defects continue through Hono's app.onError().

Guards and request-local Services

Use http.guard for authentication or other Result-based middleware:

app.use(
  '/api/private/*',
  http.guard(async function* (c) {
    const token = c.req.header('Authorization')

    if (token === undefined) {
      return Result.err(new Error('Authentication required'))
    }

    const auth = yield* AuthService
    c.set('user', yield* Result.await(auth.verify(token)))

    return Result.ok()
  })
)

An http.guard that returns Result.ok() calls the next middleware. A Result.err uses the central failure policy and stops the chain.

Request-local providers can be declared in the application Layer:

const App = HonoEffect.app(
  '@app/HonoApp',
  {
    requestLayer: (c) =>
      Layer.succeed(RequestId, new RequestId(c.req.header('X-Request-Id') ?? crypto.randomUUID()))
  },
  async function* (http) {
    const app = new Hono()
    app.use('*', yield* http.middleware())
    app.get(
      '/request-id',
      yield* http.gen(async function* () {
        const requestId = yield* RequestId
        return Result.ok(requestId)
      })
    )
    return app
  }
)

Those providers are closed with the request Scope. Shared infrastructure stays in the Runtime's root Layer and is released by runtime.dispose() during shutdown.

Hono request-scoped sessions

The optional public better-effect-better-auth/hono subpath provides request-scoped Better Auth sessions. Hono is an optional peer required only by this subpath (>=4.0.0); it is not needed by the framework-neutral . or /hooks subpaths.

Install the package from its package-qualified npm release and keep the Web handler on its normal route:

bun add better-effect-better-auth better-auth hono

Configure Better Auth's basePath to match the raw Hono route, then create one current-session token for the Better Auth Service and include the Hono app Layer in the Runtime:

import { betterAuth } from 'better-auth'
import { memoryAdapter } from 'better-auth/adapters/memory'
import { Hono } from 'hono'
import { Effect, Layer, Runtime, Service } from 'better-effect'
import { HonoEffect } from 'better-effect/hono'
import { BetterAuth } from 'better-effect-better-auth'
import { BetterAuthHono } from 'better-effect-better-auth/hono'
import { Result } from 'better-result'

class AppService extends Service<AppService>()('@app/AppService') {}
const AppLive = Layer.make(AppService)
const rawAuth = betterAuth({
  basePath: '/api/auth',
  database: memoryAdapter({
    account: [],
    session: [],
    user: [],
    verification: []
  }),
  emailAndPassword: { enabled: true },
  secret: 'replace-this-example-secret'
})
const Auth = BetterAuth.from('@app/Auth', rawAuth)
const CurrentSession = BetterAuthHono.session('@app/CurrentSession', Auth, {
  disableCookieCache: true
})
const App = HonoEffect.app(
  '@app/HonoApp',
  {
    requestLayer: CurrentSession.requestLayer,
    onFailure: (_error, c) => c.json({ error: 'Request failed' }, 500)
  },
  async function* (http) {
    const app = new Hono()

    // Register Better Auth before the catch-all boundary; it receives the original Web Request.
    app.all('/api/auth/*', (c) => rawAuth.handler(c.req.raw))
    app.use('*', yield* http.middleware())
    app.use('/api/private/*', yield* http.guard(CurrentSession.guard))

    app.get(
      '/api/private/me',
      yield* http.gen(async function* () {
        const session = yield* CurrentSession.require()
        return Result.ok({ userId: session.user.id })
      })
    )

    return app
  }
)

const runtime = await Runtime.make(Layer.merge(AppLive, Auth.layer, App.layer))
const appResult = await runtime.run(
  Effect.fn(async function* () {
    return Result.ok(yield* App)
  })
)
if (Result.isError(appResult)) throw new Error(String(appResult.error))
const app = appResult.value

The request Layer requires the exact Auth Service from the Runtime and provides only CurrentSession. Session lookup is lazy: a request that never yields CurrentSession.get() or .require() does not call Better Auth. The first lookup is cached for the request, so get(), require(), and CurrentSession.guard share one settlement, including null, API failures, and defects. A sign-in or sign-out performed after that first lookup leaves the request snapshot intentionally stale. To read the changed state deliberately, use the Auth Service with the original request: yield* auth.session.get(request). get() preserves a missing session as null; require() maps only that absence to Unauthenticated. Better Auth API failures stay typed, while unexpected throws and rejections become a new UnhandledException whose .cause holds the original defect, not the failure itself. HonoEffect's onFailure callback owns the response policy. The core package and the framework-neutral Better Auth entry point do not require Hono.

On this page