| 1 | --- |
| 2 | title: Effect HTTP |
| 3 | description: Schema-validated REST endpoints with an rpc-like typed interface — for trust boundaries where consumers want a plain HTTP client. |
| 4 | sidebar: |
| 5 | order: 4 |
| 6 | --- |
| 7 | |
| 8 | Effect HTTP (`effect/http-api`) is the same idea as |
| 9 | [Effect RPC](/apis/effect-rpc): define a Schema, construct handler |
| 10 | Layers, return an `HttpEffect` from `fetch`, call it through an |
| 11 | rpc-like typed interface. The difference is what goes on the wire — |
| 12 | real HTTP endpoints, with URLs, path params, query strings, headers, |
| 13 | bodies, and content types. |
| 14 | |
| 15 | The use case is the same trust boundary — data that needs validating |
| 16 | and sanitizing on the way in, exposed to a web app or an external |
| 17 | service. Choose Effect HTTP over Effect RPC when your consumers |
| 18 | aren't Effect (or TypeScript) programs and want a plain HTTP client. |
| 19 | The cost is also the same: every request pays for a schema decode |
| 20 | and encode, so it's equally discouraged for internal |
| 21 | service-to-service calls — that's what |
| 22 | [schemaless RPC](/apis/schemaless) is for. |
| 23 | |
| 24 | ## The pieces |
| 25 | |
| 26 | Four files. Three are the API — domain model, endpoint |
| 27 | declarations, and the host that serves them (a Cloudflare Worker or |
| 28 | Lambda Function) — and the fourth is a client that derives |
| 29 | everything from the second: |
| 30 | |
| 31 | ```sh |
| 32 | src/ |
| 33 | ├── Task.ts # domain model + typed 404 (Schema.Class, TaggedErrorClass) |
| 34 | ├── ApiSchema.ts # endpoint declarations — one HttpApi value, imported by server AND clients |
| 35 | ├── ApiService.ts # the host — a Worker or Lambda Function; wired on the hub pages — returns { fetch } |
| 36 | └── ApiClient.ts # typed client derived from ApiSchema — no codegen, no string URLs |
| 37 | ``` |
| 38 | |
| 39 | The load-bearing file is `src/ApiSchema.ts`: the server implements |
| 40 | it, every typed client is derived from it, and plain HTTP consumers |
| 41 | get its validation for free. |
| 42 | |
| 43 | ## Schema |
| 44 | |
| 45 | `src/Task.ts` owns the value that crosses the wire and the error |
| 46 | that crosses with it: |
| 47 | |
| 48 | ```typescript |
| 49 | // src/Task.ts |
| 50 | import * as Schema from "effect/Schema"; |
| 51 | |
| 52 | export class Task extends Schema.Class<Task>("Task")({ |
| 53 | id: Schema.String, |
| 54 | title: Schema.String, |
| 55 | completed: Schema.Boolean, |
| 56 | }) {} |
| 57 | |
| 58 | export class TaskNotFound extends Schema.TaggedErrorClass<TaskNotFound>()( |
| 59 | "TaskNotFound", |
| 60 | { id: Schema.String }, |
| 61 | { httpApiStatus: 404 }, |
| 62 | ) {} |
| 63 | ``` |
| 64 | |
| 65 | `Schema.Class` gives you a runtime-validated class with an inferred |
| 66 | TypeScript type — one definition imported by both sides — and |
| 67 | `httpApiStatus: 404` maps the tagged error to a real 404 over plain |
| 68 | HTTP instead of a generic 500. |
| 69 | |
| 70 | ## Endpoints |
| 71 | |
| 72 | `src/ApiSchema.ts` declares the contract — endpoints, a group, and |
| 73 | the API value: |
| 74 | |
| 75 | ```typescript |
| 76 | // src/ApiSchema.ts |
| 77 | import * as Schema from "effect/Schema"; |
| 78 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 79 | import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; |
| 80 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 81 | import { Task, TaskNotFound } from "./Task.ts"; |
| 82 | |
| 83 | export const getTask = HttpApiEndpoint.get("getTask", "/:id", { |
| 84 | params: Schema.Struct({ id: Schema.String }), |
| 85 | success: Task, |
| 86 | error: TaskNotFound, |
| 87 | }); |
| 88 | |
| 89 | export const createTask = HttpApiEndpoint.post("createTask", "/", { |
| 90 | payload: Schema.Struct({ title: Schema.String }), |
| 91 | success: Task, |
| 92 | }); |
| 93 | |
| 94 | export class TasksGroup extends HttpApiGroup.make("Tasks") |
| 95 | .add(getTask) |
| 96 | .add(createTask) {} |
| 97 | |
| 98 | export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {} |
| 99 | ``` |
| 100 | |
| 101 | Endpoints are pure declarations of `(method, path, schemas)`: the |
| 102 | `/:id` path parameter declared under `params` is what makes |
| 103 | `params.id` a typed `string` in both the handler and the client, |
| 104 | request bodies are decoded against the `payload` Schema before your |
| 105 | handler runs (anything that doesn't match gets an automatic 400 with |
| 106 | a structured validation error), and `TaskApi` is a plain value — |
| 107 | importable by the server and every client without dragging any |
| 108 | runtime code across. |
| 109 | |
| 110 | ## Handlers |
| 111 | |
| 112 | `HttpApiBuilder.group` wires handlers into the spec: |
| 113 | |
| 114 | ```typescript |
| 115 | // src/ApiService.ts — inside the host's Construction phase (a Cloudflare Worker or Lambda Function) |
| 116 | import * as Effect from "effect/Effect"; |
| 117 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 118 | import { TaskApi } from "./ApiSchema.ts"; |
| 119 | import { Task, TaskNotFound } from "./Task.ts"; |
| 120 | |
| 121 | const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => |
| 122 | handlers |
| 123 | .handle("getTask", ({ params }) => |
| 124 | // look up the task in storage — the hub guides wire R2/DynamoDB |
| 125 | Effect.fail(new TaskNotFound({ id: params.id })), |
| 126 | ) |
| 127 | .handle("createTask", ({ payload }) => |
| 128 | Effect.succeed( |
| 129 | new Task({ |
| 130 | id: crypto.randomUUID(), |
| 131 | title: payload.title, |
| 132 | completed: false, |
| 133 | }), |
| 134 | ), |
| 135 | ), |
| 136 | ); |
| 137 | ``` |
| 138 | |
| 139 | `HttpApiBuilder.group` *constructs* a handler Layer — it runs |
| 140 | nothing, which is what makes it safe at Construction/plan time |
| 141 | ([Phases](/infrastructure-as-effects/phases)) — and each |
| 142 | handler receives the typed request its endpoint declared: |
| 143 | `params.id` and `payload.title` are `string`s, the handler must |
| 144 | return a `Task`, and the only allowed failure is `TaskNotFound` — a |
| 145 | wrong shape or an undeclared error is a compile error, not a runtime |
| 146 | surprise. |
| 147 | |
| 148 | ## Serve it |
| 149 | |
| 150 | Assemble the API Layer and convert it into the `HttpEffect` that |
| 151 | `fetch` expects: |
| 152 | |
| 153 | ```typescript |
| 154 | // src/ApiService.ts — end of the Construction phase |
| 155 | import * as Layer from "effect/Layer"; |
| 156 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 157 | |
| 158 | // platform = the host's platform service Layers — differs per host |
| 159 | return { |
| 160 | fetch: yield* HttpRouter.toHttpEffect( |
| 161 | HttpApiBuilder.layer(TaskApi).pipe( |
| 162 | Layer.provide(tasksGroup), |
| 163 | Layer.provide(platform), |
| 164 | ), |
| 165 | ), |
| 166 | }; |
| 167 | ``` |
| 168 | |
| 169 | `HttpRouter.toHttpEffect` builds the Layer once at boot and yields |
| 170 | the request handler — the same `{ fetch }` shape a Worker or a |
| 171 | Lambda Function with `functionUrl: true` serves. The `platform` Layers are |
| 172 | the host-specific part of the wiring — see |
| 173 | [Workers](/cloudflare/apis/effect-http-api) and |
| 174 | [Lambda](/aws/apis/effect-http-api) for the exact set, plus storage |
| 175 | bindings and deploy (and CORS, on Workers). |
| 176 | |
| 177 | ## Plain HTTP for everyone else |
| 178 | |
| 179 | Deploy it and the payoff over Effect RPC is immediate: the wire |
| 180 | format is ordinary REST, so consumers need nothing but an HTTP |
| 181 | client. |
| 182 | |
| 183 | ```sh |
| 184 | curl -X POST https://<your-api-url>/ \ |
| 185 | -H "Content-Type: application/json" \ |
| 186 | -d '{"title": "Write docs"}' |
| 187 | # → {"id":"...","title":"Write docs","completed":false} |
| 188 | |
| 189 | curl https://<your-api-url>/<id> |
| 190 | # → {"id":"...","title":"Write docs","completed":false} |
| 191 | ``` |
| 192 | |
| 193 | Invalid payloads get automatic 400 responses with structured |
| 194 | validation errors, and a missing task comes back as a real 404 — the |
| 195 | `httpApiStatus` declared on `TaskNotFound` — in any language, with |
| 196 | any HTTP library, against the same validated contract. |
| 197 | |
| 198 | ## A typed client |
| 199 | |
| 200 | For consumers that *are* Effect programs, the same `TaskApi` value |
| 201 | drives a fully typed client — no codegen, no string URLs: |
| 202 | |
| 203 | ```typescript |
| 204 | // src/ApiClient.ts |
| 205 | import * as Effect from "effect/Effect"; |
| 206 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 207 | import { TaskApi } from "./ApiSchema.ts"; |
| 208 | |
| 209 | const program = Effect.gen(function* () { |
| 210 | const client = yield* HttpApiClient.make(TaskApi, { |
| 211 | baseUrl: process.env.TASK_API_URL!, |
| 212 | }); |
| 213 | |
| 214 | const created = yield* client.Tasks.createTask({ |
| 215 | payload: { title: "Write docs" }, |
| 216 | }); |
| 217 | |
| 218 | const task = yield* client.Tasks.getTask({ |
| 219 | params: { id: created.id }, |
| 220 | }); |
| 221 | }); |
| 222 | ``` |
| 223 | |
| 224 | Request keys mirror the declarations — `payload` for bodies, |
| 225 | `params` for path parameters — and `client.Tasks.getTask` returns |
| 226 | `Effect<Task, TaskNotFound | HttpClientError>`: the 404 arrives as a |
| 227 | typed value you pattern-match on, not a status code you interpret. |
| 228 | The client can also ride a Binding instead of a URL — see the |
| 229 | [Durable Object bridge](/cloudflare/apis/effect-http-api#bridge-the-do-into-a-typed-client) |
| 230 | on the Workers page. |
| 231 | |
| 232 | ## Where next |
| 233 | |
| 234 | - [Effect HTTP on Workers](/cloudflare/apis/effect-http-api) — full |
| 235 | Worker wiring: R2 storage, the `HttpPlatform` stub, CORS, and the |
| 236 | DO sub-API bridge. |
| 237 | - [Effect HTTP on Lambda](/aws/apis/effect-http-api) — full Lambda |
| 238 | wiring: DynamoDB bindings, IAM, and a typed end-to-end test. |
| 239 | - [Effect RPC](/apis/effect-rpc) — same trust boundary, leaner wire |
| 240 | protocol, for consumers that are Effect programs. |
| 241 | - [Schemaless RPC](/apis/schemaless) — the default for internal |
| 242 | calls: typed clients with no schema and no per-request validation. |
| 243 | - [REST API (API Gateway v1)](/aws/apis/api-gateway) — put API |
| 244 | Gateway in front instead of a Function URL. |