Skip to content

Defining APIs

An API definition is a pure, dependency-free description of your endpoints. It contains no handler logic — just schemas — so it can live in a shared package and be imported by both the server and any clients. This page covers the three building blocks: endpoints, groups, and the root HttpApi.

All three modules live under effect/unstable/httpapi:

import {
HttpApi,
HttpApiEndpoint,
HttpApiError,
HttpApiGroup,
HttpApiSchema
} from "effect/unstable/httpapi"

Endpoints reference your domain types, so start there. Define them with Schema so they decode and encode automatically.

domain/User.ts
import { Schema } from "effect"
// A branded id so a UserId can never be confused with a plain number
export const UserId = Schema.Int.pipe(Schema.brand("UserId"))
export type UserId = typeof UserId.Type
export class User extends Schema.Class<User>("User")({
id: UserId,
name: Schema.String,
email: Schema.String
}) {}

Errors are schemas too. Use Schema.TaggedErrorClass and attach an HTTP status code with the httpApiStatus annotation so the framework knows how to encode them on the wire:

domain/UserErrors.ts
import { Schema } from "effect"
export class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()(
"UserNotFound",
{},
// The third argument is annotations: this error responds with 404
{ httpApiStatus: 404 }
) {}
export class SearchQueryTooShort extends Schema.TaggedErrorClass<SearchQueryTooShort>()(
"SearchQueryTooShort",
{},
{ httpApiStatus: 422 }
) {
static readonly minimumLength = 2
}
// A single wrapper error for the Users service. Its `reason` field is a tagged
// union of the domain failures, so the reason-combinators (Effect.catchReason /
// catchReasons / unwrapReason) can target an individual reason by its `_tag`.
export class UsersError extends Schema.TaggedErrorClass<UsersError>()(
"UsersError",
{
reason: Schema.Union([UserNotFound, SearchQueryTooShort])
}
) {}

So new UsersError({ reason: new UserNotFound() }) is the service’s only failure type, and handlers can pick reasons apart with Effect.catchReason("UsersError", "UserNotFound", ...) (see Handlers).

An endpoint is created with a method helper (HttpApiEndpoint.get, .post, .put, .patch, .delete, .head, .options). You give it a name (used as the client method name), a path, and an options object describing the inputs and outputs.

api/Users.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { User, UserId } from "../domain/User.ts"
import { SearchQueryTooShort, UserNotFound } from "../domain/UserErrors.ts"
export class UsersApiGroup extends HttpApiGroup.make("users")
.add(
// Query parameters come from the query string and are all decoded.
HttpApiEndpoint.get("list", "/", {
query: { search: Schema.optional(Schema.String) },
success: Schema.Array(User)
}),
// Path parameters must decode *from a string*. Use Schema.decodeTo to
// bridge from a string schema into your branded domain type.
HttpApiEndpoint.get("getById", "/:id", {
params: {
id: Schema.FiniteFromString.pipe(Schema.decodeTo(UserId))
},
success: User,
// This error returns 404 with no body (see "Empty responses" below).
error: UserNotFound.pipe(
HttpApiSchema.asNoContent({ decode: () => new UserNotFound() })
)
}),
// For methods with a body (POST/PUT/PATCH) `payload` is the request body,
// JSON by default.
HttpApiEndpoint.post("create", "/", {
payload: Schema.Struct({
name: Schema.String,
email: Schema.String
}),
success: User
}),
// DELETE has no request body, so `payload` (if any) is read from the query
// string just like a GET.
HttpApiEndpoint.delete("remove", "/:id", {
params: { id: Schema.FiniteFromString.pipe(Schema.decodeTo(UserId)) },
success: HttpApiSchema.NoContent
}),
// An endpoint can declare multiple success or error schemas as an array.
HttpApiEndpoint.get("search", "/search", {
// For GET requests `payload` is read from the query string.
payload: { search: Schema.String },
success: [
Schema.Array(User),
// Negotiate a CSV response with a different content type
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/csv" }))
],
error: [
SearchQueryTooShort.pipe(
HttpApiSchema.asNoContent({ decode: () => new SearchQueryTooShort() })
),
// Built-in errors cover common HTTP cases
HttpApiError.RequestTimeoutNoContent
]
})
)
.prefix("/users")
{}

Each is optional and accepts either a Schema.Struct (or any single Schema) or a plain record of field schemas (a shorthand for a struct):

OptionSourceNotes
paramsPath segments (/:id)Must decode from a string.
queryQuery stringDecoded individually.
headersRequest headersDecoded individually.
payloadRequest body for WithBody methods, query string for no-body methodsJSON by default for body methods; form-url-encoded for no-body methods.

By default codecs are applied automatically: params, query, and headers use string-tree codecs, body payloads use JSON codecs, and no-body payloads use form-url-encoded codecs. Pass { disableCodecs: true } in the options object if you have already wrapped the schemas in the right transport codec yourself.

success defaults to HttpApiSchema.NoContent (a 204 with no body). Provide a schema — or an array of schemas — to describe the response body. error declares the failure schemas; their status codes come from the httpApiStatus annotation or from HttpApiSchema.status. When you pass an array, each schema can declare a different status code, and the server/clients select by status.

HttpApiSchema controls encoding and status:

import { HttpApiSchema } from "effect/unstable/httpapi"
import { Schema } from "effect"
HttpApiSchema.NoContent // 204, no body
HttpApiSchema.Created // 201, no body
HttpApiSchema.Accepted // 202, no body
// Force a status code onto any schema
const Gone = Schema.Void.pipe(HttpApiSchema.status(410))
// Turn an error schema into a no-content response, supplying a decoder used
// when reconstructing the error on the client.
UserNotFound.pipe(HttpApiSchema.asNoContent({ decode: () => new UserNotFound() }))

Other encoders include asText, asJson, asFormUrlEncoded, asUint8Array, asMultipart, and asMultipartStream for content-type negotiation and binary or file payloads — all documented in the HttpApiSchema reference below.

HttpApiGroup.make bundles endpoints. Groups support .prefix(...) to add a common path segment, .middleware(...) to apply middleware to every endpoint, and .annotateMerge(...) for OpenAPI metadata.

import { HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
class UsersApiGroup extends HttpApiGroup.make("users")
.add(/* ...endpoints... */)
.prefix("/users")
.annotateMerge(OpenApi.annotations({
title: "Users",
description: "User management endpoints"
}))
{}

Pass { topLevel: true } to attach a group’s endpoints directly to the root of the generated client, instead of namespacing them under the group name. This is handy for system endpoints like health checks:

import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
// Reachable as `client.health()` rather than `client.system.health()`
export class SystemApi extends HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.get("health", "/health", {
success: HttpApiSchema.NoContent
})
) {}

HttpApi.make creates the root. Add groups with .add(...), and annotate the whole API with OpenAPI metadata:

api/Api.ts
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { SystemApi } from "./System.ts"
import { UsersApiGroup } from "./Users.ts"
// This is the value you serve and generate clients from.
export class Api extends HttpApi.make("user-api")
.add(UsersApiGroup)
.add(SystemApi)
.annotateMerge(OpenApi.annotations({ title: "Acme User API" }))
{}

With the definition in place, the next step is to implement handlers for each endpoint.


The remainder of this page is an exhaustive reference for every public API used to define endpoints, schemas, errors, groups, and the root API.

An HttpApiEndpoint is a declaration, not a handler: it records a name, HTTP method, path, request schemas (params/query/headers/payload), response schemas (success/error), endpoint middleware, and annotations. Builders use it to decode requests, type handler input, encode responses, and generate OpenAPI/clients.

Method constructors: get / post / put / patch / delete / head / options

Section titled “Method constructors: get / post / put / patch / delete / head / options”

Each helper builds an endpoint for a specific HTTP method. The signature is (name, path, options?). delete is exported under the reserved name, so you call it as a property: HttpApiEndpoint.delete(...).

import { Schema } from "effect"
import { HttpApiEndpoint } from "effect/unstable/httpapi"
HttpApiEndpoint.get("list", "/users") // method "GET"
HttpApiEndpoint.post("create", "/users") // method "POST"
HttpApiEndpoint.put("replace", "/users/:id") // method "PUT"
HttpApiEndpoint.patch("update", "/users/:id")// method "PATCH"
HttpApiEndpoint.delete("remove", "/users/:id")// method "DELETE"
HttpApiEndpoint.head("exists", "/users/:id") // method "HEAD"
HttpApiEndpoint.options("opts", "/users") // method "OPTIONS"
const ep = HttpApiEndpoint.post("create", "/users", {
payload: Schema.Struct({ name: Schema.String }),
success: Schema.Struct({ id: Schema.Number })
})
ep.name // => "create"
ep.method // => "POST"
ep.path // => "/users"

The optional third argument fully describes the route. All fields are optional:

import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiSchema, HttpApiError } from "effect/unstable/httpapi"
HttpApiEndpoint.post("create", "/users/:teamId", {
// Path parameters from `/:teamId`. A record is shorthand for Schema.Struct.
params: { teamId: Schema.String },
// Query string values.
query: { dryRun: Schema.optional(Schema.String) },
// Request headers.
headers: { "x-trace-id": Schema.optional(Schema.String) },
// Request body (JSON by default for body methods).
payload: Schema.Struct({ name: Schema.String }),
// Success response schema(s). Single schema or array.
success: Schema.Struct({ id: Schema.Number }),
// Error response schema(s). Single schema or array.
error: HttpApiError.Conflict,
// Set true if your schemas are already wrapped in transport codecs.
disableCodecs: false
})

success defaults to HttpApiSchema.NoContent. params, query, headers, payload, and error default to “none”. Multiple payload schemas may share a content type only if they use the same encoding strategy; multipart payloads cannot be combined under the same content type.

Creates the method-specific constructor used by get/post/etc. Use it directly only when you need a method the helpers do not cover.

import { HttpApiEndpoint } from "effect/unstable/httpapi"
const trace = HttpApiEndpoint.make("TRACE")
const ep = trace("trace", "/debug")
ep.method // => "TRACE"

Type guard that narrows an unknown value to an endpoint.

import { HttpApiEndpoint } from "effect/unstable/httpapi"
const ep = HttpApiEndpoint.get("list", "/")
HttpApiEndpoint.isHttpApiEndpoint(ep) // => true
HttpApiEndpoint.isHttpApiEndpoint({}) // => false

Returns a new endpoint with a path prefix prepended.

import { HttpApiEndpoint } from "effect/unstable/httpapi"
const ep = HttpApiEndpoint.get("list", "/").prefix("/users")
ep.path // => "/users/"

Attaches an HttpApiMiddleware tag to a single endpoint, merging its required services and declared errors into the endpoint.

// declare a middleware tag elsewhere, then:
// HttpApiEndpoint.get("list", "/").middleware(Authorization)

Attach annotations (e.g. OpenAPI metadata) to a single endpoint. annotate adds one Context.Key/value pair; annotateMerge merges an entire Context.

import { Context } from "effect"
import { HttpApiEndpoint } from "effect/unstable/httpapi"
import { OpenApi } from "effect/unstable/httpapi"
const ep = HttpApiEndpoint.get("list", "/")
.annotateMerge(OpenApi.annotations({ description: "List all users" }))
// ep.annotations is a Context.Context<never>
Context.isContext(ep.annotations) // => true

HttpApiSchema does not define routes or perform IO — it annotates Schema values so the surrounding HTTP API tooling can pick status codes, content types, and body codecs. Keep Schema as the source of validation/transformation, then layer on these helpers.

A Schema.Void annotated with status 204. The default success schema.

import { HttpApiSchema } from "effect/unstable/httpapi"
HttpApiSchema.NoContent // => Schema.Void with httpApiStatus 204

A Schema.Void annotated with status 201.

import { HttpApiSchema } from "effect/unstable/httpapi"
HttpApiSchema.Created // => Schema.Void with httpApiStatus 201

A Schema.Void annotated with status 202.

import { HttpApiSchema } from "effect/unstable/httpapi"
HttpApiSchema.Accepted // => Schema.Void with httpApiStatus 202

Creates a Schema.Void with an arbitrary status code — the building block behind NoContent/Created/Accepted.

import { HttpApiSchema } from "effect/unstable/httpapi"
const Gone = HttpApiSchema.Empty(410) // => Schema.Void with httpApiStatus 410

Sets the httpApiStatus annotation on any schema. Accepts a numeric code or a common status literal (e.g. "Created", "NotFound").

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
const A = Schema.String.pipe(HttpApiSchema.status(418)) // => 418
const B = Schema.String.pipe(HttpApiSchema.status("Created"))// => 201

Marks a schema (whose Encoded is a string) as a text/plain payload or response. Pass contentType to override.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
// encode: value -> "text/plain" body ; decode: body string -> value
Schema.String.pipe(HttpApiSchema.asText())
// negotiate a CSV content type while still sending text
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/csv" }))

Marks a schema as a JSON payload/response (application/json). This is already the default for body payloads and responses; use it to be explicit or to set a custom JSON-family content type.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
Schema.Struct({ ok: Schema.Boolean }).pipe(HttpApiSchema.asJson())
Schema.Struct({ ok: Schema.Boolean }).pipe(
HttpApiSchema.asJson({ contentType: "application/vnd.api+json" })
)

Marks a schema as application/x-www-form-urlencoded. The schema’s encoded side must be a record of strings. This is the default payload encoding for no-body methods.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
// decode: "a=1&b=2" -> { a: "1", b: "2" } ; encode: record -> form body
Schema.Struct({ a: Schema.String, b: Schema.String }).pipe(
HttpApiSchema.asFormUrlEncoded()
)

Marks a schema (whose Encoded is a Uint8Array) as a binary application/octet-stream payload/response.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array())
Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array({ contentType: "image/png" }))

Marks a payload schema as buffered multipart/form-data. The whole body is read into memory before the handler runs. Accepts optional Multipart limits.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
const Upload = Schema.Struct({
title: Schema.String
}).pipe(HttpApiSchema.asMultipart())
// handler receives the fully-decoded value

Marks a payload schema as a streaming multipart payload. The handler receives a Stream.Stream<Multipart.Part, Multipart.MultipartError> instead of a buffered value — useful for large uploads.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
const Upload = Schema.Struct({ field: Schema.String }).pipe(
HttpApiSchema.asMultipartStream()
)
// request.payload is a Stream of Multipart.Part

Marks a schema as a no-content response while still producing a useful decoded value on the client. The server encodes void; the client calls decode to reconstruct a value when the response has no body.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
// wire: empty body ; client decodes to { acknowledged: true }
const Ack = Schema.Struct({ acknowledged: Schema.Boolean }).pipe(
HttpApiSchema.asNoContent({ decode: () => ({ acknowledged: true }) })
)

Predicate over a schema AST; returns true when the schema represents a no-content (void) response, including transformations whose encoded target is void.

import { HttpApiSchema } from "effect/unstable/httpapi"
HttpApiSchema.isNoContent(HttpApiSchema.NoContent.ast) // => true

Resolve the effective status code for a schema AST. getStatusSuccess defaults to 200, getStatusError to 500, when no httpApiStatus annotation is present.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
HttpApiSchema.getStatusSuccess(Schema.String.ast) // => 200
HttpApiSchema.getStatusError(Schema.String.ast) // => 500
HttpApiSchema.getStatusSuccess(HttpApiSchema.Created.ast) // => 201

Resolve the encoding (Json / FormUrlEncoded / Text / Uint8Array / Multipart) chosen for a payload or response. Payload encoding depends on the HTTP method: body methods default to JSON, no-body methods to form-url-encoded. Response encoding rejects multipart.

import { Schema } from "effect"
import { HttpApiSchema } from "effect/unstable/httpapi"
HttpApiSchema.getPayloadEncoding(Schema.Struct({}).ast, "POST")._tag // => "Json"
HttpApiSchema.getPayloadEncoding(Schema.Struct({}).ast, "GET")._tag // => "FormUrlEncoded"
HttpApiSchema.getResponseEncoding(Schema.String.ast)._tag // => "Json"

HttpApiError provides reusable Schema.ErrorClass values for common HTTP status codes. Each carries an httpApiStatus annotation and renders as an empty response with the matching status when used directly as a server response. Declaring one as an endpoint error tells the server how to encode it and tells generated clients how to decode it.

import { HttpApiEndpoint, HttpApiError } from "effect/unstable/httpapi"
HttpApiEndpoint.get("getById", "/:id", {
error: HttpApiError.NotFound // responds 404
})

Each class is a tagged error with a fixed status code:

ClassStatusClassStatus
BadRequest400Gone410
Unauthorized401InternalServerError500
Forbidden403NotImplemented501
NotFound404ServiceUnavailable503
MethodNotAllowed405
NotAcceptable406
RequestTimeout408
Conflict409
import { HttpApiError } from "effect/unstable/httpapi"
const err = new HttpApiError.Conflict()
err._tag // => "Conflict"
// HttpApiError.Conflict has httpApiStatus 409
// `BadRequest` ships a reusable singleton instance
HttpApiError.BadRequest.singleton._tag // => "BadRequest"

Every status error has a matching *NoContent export: a schema that decodes an empty response of that status into the corresponding error value. Use these when the wire response intentionally has no body but clients should still decode a typed error.

import { HttpApiEndpoint, HttpApiError } from "effect/unstable/httpapi"
HttpApiEndpoint.get("getById", "/:id", {
// empty 404 on the wire, decoded to a NotFound error on the client
error: HttpApiError.NotFoundNoContent
})

Available variants: BadRequestNoContent, UnauthorizedNoContent, ForbiddenNoContent, NotFoundNoContent, MethodNotAllowedNoContent, NotAcceptableNoContent, RequestTimeoutNoContent, ConflictNoContent, GoneNoContent, InternalServerErrorNoContent, NotImplementedNoContent, and ServiceUnavailableNoContent.

The error the HTTP API runtime raises when a request component fails schema decoding. It records which component failed via kind ("Params", "Headers", "Query", "Body", or "Payload") and responds as an empty 400 Bad Request.

import { HttpApiError } from "effect/unstable/httpapi"
declare const value: unknown
if (HttpApiError.HttpApiSchemaError.is(value)) {
value.kind // => one of "Params" | "Headers" | "Query" | "Body" | "Payload"
}

An HttpApiGroup is a named collection of endpoints — the boundary for a single resource or feature area. In generated clients a regular group becomes a named namespace; a topLevel group exposes its endpoint methods directly.

Creates an empty group with the given identifier. Pass { topLevel: true } to flatten its endpoints onto the client root.

import { HttpApiGroup } from "effect/unstable/httpapi"
HttpApiGroup.make("users") // namespaced as `client.users.*`
HttpApiGroup.make("system", { topLevel: true }) // methods on the client root
const g = HttpApiGroup.make("users")
g.identifier // => "users"
g.topLevel // => false

Adds one or more endpoints. Adding an endpoint whose name already exists replaces it.

import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
const g = HttpApiGroup.make("users").add(
HttpApiEndpoint.get("list", "/"),
HttpApiEndpoint.get("getById", "/:id")
)
Object.keys(g.endpoints) // => ["list", "getById"]

Prepends a path prefix to every endpoint already in the group.

import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
const g = HttpApiGroup.make("users")
.add(HttpApiEndpoint.get("list", "/"))
.prefix("/users")
g.endpoints.list.path // => "/users/"

Applies an HttpApiMiddleware to every endpoint currently in the group. Endpoints added later are not affected.

// HttpApiGroup.make("users").add(...).middleware(Authorization)

Attach annotations to the group itself (not its endpoints). annotate adds a single key/value; annotateMerge merges a Context. Use .annotateEndpoints / .annotateEndpointsMerge to annotate every current endpoint instead.

import { HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
HttpApiGroup.make("users")
.annotateMerge(OpenApi.annotations({ title: "Users" }))

Type guard narrowing an unknown value to a group.

import { HttpApiGroup } from "effect/unstable/httpapi"
HttpApiGroup.isHttpApiGroup(HttpApiGroup.make("users")) // => true
HttpApiGroup.isHttpApiGroup({}) // => false

The root HttpApi is the shared contract consumed by server builders, generated clients, URL builders, OpenAPI generation, and reflection. Handlers are supplied later with HttpApiBuilder.group, and the API is registered with HttpApiBuilder.layer (see Serving & clients).

Creates an empty root API with a stable identifier.

import { HttpApi } from "effect/unstable/httpapi"
const api = HttpApi.make("user-api")
api.identifier // => "user-api"

Adds one or more groups. Group identifiers are keys — adding a group with the same identifier replaces the previous one.

import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi"
const api = HttpApi.make("user-api")
.add(HttpApiGroup.make("users"))
.add(HttpApiGroup.make("system", { topLevel: true }))
Object.keys(api.groups) // => ["users", "system"]

Merges another HttpApi’s groups into this one, merging the added API’s annotations into its groups.

import { HttpApi } from "effect/unstable/httpapi"
const a = HttpApi.make("a")
const b = HttpApi.make("b")
const merged = a.addHttpApi(b)

prefix prepends a path to every endpoint in every group currently present; middleware applies an HttpApiMiddleware to them. Both only affect groups/endpoints present when called.

import { HttpApi, HttpApiGroup, HttpApiEndpoint } from "effect/unstable/httpapi"
HttpApi.make("user-api")
.add(HttpApiGroup.make("users").add(HttpApiEndpoint.get("list", "/")))
.prefix("/api")

Attach annotations to the whole API (e.g. OpenAPI title/description).

import { HttpApi, OpenApi } from "effect/unstable/httpapi"
HttpApi.make("user-api")
.annotateMerge(OpenApi.annotations({ title: "Acme User API" }))

Type guard narrowing an unknown value to an HttpApi.

import { HttpApi } from "effect/unstable/httpapi"
HttpApi.isHttpApi(HttpApi.make("user-api")) // => true
HttpApi.isHttpApi({}) // => false

Walks the final group and endpoint metadata — with merged annotations, status-indexed success/error schemas, and middleware — invoking onGroup and onEndpoint callbacks. This is what OpenAPI generation and client derivation build on; reach for it when writing your own tooling.

import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
const api = HttpApi.make("user-api").add(
HttpApiGroup.make("users").add(HttpApiEndpoint.get("list", "/"))
)
HttpApi.reflect(api, {
onGroup: ({ group }) => {
group.identifier // => "users"
},
onEndpoint: ({ endpoint, successes, errors }) => {
endpoint.name // => "list"
// `successes` / `errors` are ReadonlyMap<status, schemas>
successes.has(204) // => true (NoContent default)
}
})

A Context.Service holding extra schemas to emit under components/schemas in generated OpenAPI. Each schema must have an identifier annotation. Provide it when registering the API (see OpenAPI).

import { Layer, Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
const layer = Layer.succeed(HttpApi.AdditionalSchemas, [
Schema.String.annotate({ identifier: "MyString" })
])