Skip to content

Handling Responses

A HttpClientResponse carries the status, headers, and a lazily-read body. You can read the body in several shapes — raw text, JSON, bytes, or a stream — but the idiomatic path is to decode it through a Schema, so the value you work with is fully typed and validated. Decoding helpers live on HttpClientResponse, and they are designed to be used with Effect.flatMap right after the request.

import { Effect, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String,
email: Schema.String
}) {}
const getUser = Effect.fn("getUser")(function*(id: number) {
const client = yield* HttpClient.HttpClient
return yield* client.get(`https://api.example.com/users/${id}`).pipe(
// schemaBodyJson reads the JSON body and decodes it with the schema.
// It fails with HttpClientError (read/empty body) or Schema.SchemaError
// (the body did not match the shape).
Effect.flatMap(HttpClientResponse.schemaBodyJson(User))
)
})

When you do not need a schema, read the body through the accessors on the response. Each one is an Effect, because reading can fail:

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
const raw = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const response = yield* client.get("https://api.example.com/health")
// response.text : Effect<string, HttpClientError>
// response.json : Effect<Schema.Json, HttpClientError> (parsed JSON)
// response.arrayBuffer : Effect<ArrayBuffer, HttpClientError>
// response.stream : Stream<Uint8Array, HttpClientError>
const text = yield* response.text
return { status: response.status, headers: response.headers, text }
})

The response also exposes status, headers, and cookies synchronously, plus a formData effect for multipart/form-data responses.

| Helper | Reads | Decodes | | --- | --- | --- | | schemaBodyJson(schema) | JSON body | the body against schema | | schemaJson(schema) | JSON body | { status, headers, body } together | | schemaNoBody(schema) | nothing | { status, headers } only |

schemaJson is handy when the contract spans more than the body — for example when a header or the status code is part of the decoded value:

import { Effect, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
// Decode the status, a header, and the body in one pass.
const Paged = Schema.Struct({
status: Schema.Number,
headers: Schema.Struct({
"x-total-count": Schema.String
}),
body: Schema.Array(Schema.String)
})
const listTags = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
return yield* client.get("https://api.example.com/tags").pipe(
Effect.flatMap(HttpClientResponse.schemaJson(Paged))
)
})

Sometimes the response shape depends on the status code — a 404 means “absent” rather than “error”, or a 2xx and a 4xx decode to different schemas. HttpClientResponse.matchStatus lets you handle exact codes and status classes ("2xx", "3xx", "4xx", "5xx") with a required orElse:

import { Effect, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String
}) {}
const findUser = Effect.fn("findUser")(function*(id: number) {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(`https://api.example.com/users/${id}`)
return yield* response.pipe(
HttpClientResponse.matchStatus({
// 404 is an expected outcome, not a failure.
404: () => Effect.succeed(Option.none<User>()),
// Any other 2xx: decode the body.
"2xx": (ok) =>
HttpClientResponse.schemaBodyJson(User)(ok).pipe(Effect.map(Option.some)),
// Everything else is unexpected — surface the response.
orElse: (other) =>
Effect.fail(new Error(`Unexpected status ${other.status}`))
})
)
})

For large or open-ended responses, read the body as a Stream of bytes instead of buffering it. response.stream is a Stream<Uint8Array, HttpClientError>, and HttpClientResponse.stream lifts a response effect straight into a stream so you can pipe a request directly into stream processing:

import { Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
const downloadLineCount = Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
// HttpClientResponse.stream takes the request effect and yields the body as a
// byte stream — nothing is buffered into memory all at once.
const bytes = HttpClientResponse.stream(
client.get("https://api.example.com/export.csv")
)
return yield* bytes.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runCount
)
})

See Streaming for the full set of stream operators. Once you can read responses, the next step is making the client resilient — retries, timeouts, and rate limiting — covered in Resilience.