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.

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
}),
// 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 a plain record of field schemas (a shorthand for a struct):

| Option | Source | Notes | | --------- | ------------------------------- | ----- | | params | Path segments (/:id) | Must decode from a string. | | query | Query string | Decoded individually. | | headers | Request headers | Decoded individually. | | payload | Request body for WithBody methods, query string for GET/HEAD | JSON by default. |

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.

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.

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.