09·03SWAPI API Client

A real API call lights up every piece of Effect you've learned so far. This capstone wires `Config` (25), `Schema` (18-20), `Context.Service` (23), typed errors (04, 09), `Layer` composition (22, 24), `ConfigProvider.fromUnknown` for tests (25, 29), and `Manag

50a - Capstone: SWAPI API Client

Previous · Next

A real API call lights up every piece of Effect you’ve learned so far. This capstone wires Config (25), Schema (18-20), Context.Service (23), typed errors (04, 09), Layer composition (22, 24), ConfigProvider.fromUnknown for tests (25, 29), and ManagedRuntime (60) into one client — pointed at SWAPI (the Star Wars API).

What you’ll work through

index.test.ts builds a SwapiClient service one beat at a time:

  • A HttpClient service abstracts fetch so tests can swap it without touching the network.
  • BuildSwapiUrl reads the base URL from Config — production gets it from process.env, tests inject it via ConfigProvider.fromUnknown.
  • SwapiClient.getPerson(id) returns Effect<Person, FetchError | JsonError | DecodeError | NotFoundError, …>. The return type is the spec.
  • Each error gets its own Data.TaggedError so Effect.catchTags({...}) is exhaustive — the compiler refuses to let you ignore a branch.
  • A ManagedRuntime composes SwapiClient.layer (which pulls in BuildSwapiUrl.layer via dependencies) so the test harness boots the whole graph once and reuses it across every it(...).

By the end you should be able to read SwapiClient.getPerson("1") and predict the four ways it can fail, the one service it needs (HttpClient), and the one config key it reads (SWAPI_BASE_URL).


Context.Service with dependencies

import { Effect } from "effect"
class SwapiClient extends Context.Service<SwapiClient>()("SwapiClient", {
effect: Effect.gen(function* () {
const url = yield* BuildSwapiUrl
const http = yield* HttpClient
return {
getPerson: (id: string) => /* … */,
}
}),
dependencies: [BuildSwapiUrl.layer, HttpClient.layer],
}) {}

Context.Service bakes the dependency graph into the service declaration. SwapiClient.layer is a Layer that automatically pulls in BuildSwapiUrl.layer and HttpClient.layer. The call site provides one layer; the rest is wired by tags.


Config from a map (no env vars in tests)

import { Config, ConfigProvider, Layer } from "effect"
const TestConfig = Layer.setConfigProvider(
ConfigProvider.fromUnknown(new Map([["SWAPI_BASE_URL", "https://swapi.info/api"]])),
)

Tests inject configuration through a ConfigProvider, not through process.env. The BuildSwapiUrl service reads Config.string("SWAPI_BASE_URL") and doesn’t care whether the value came from a map or the environment — that’s the whole point of dependency injection at the config layer.


Four typed failure modes

import { Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{ cause: unknown }> {}
class JsonError extends Data.TaggedError("JsonError")<{ cause: unknown }> {}
class NotFoundError extends Data.TaggedError("NotFoundError")<{ id: string }> {}
class DecodeError extends Data.TaggedError("DecodeError")<{ cause: unknown }> {}

fetch collapses every failure into a thrown promise. Effect makes them four distinct tagged classes:

  • FetchError — network or 5xx
  • NotFoundError — server responded 404
  • JsonError — body wasn’t valid JSON
  • DecodeError — JSON didn’t match Schema.Struct

The return type lists every one of them. There’s nothing else this function can do.


Schema as the wire contract

import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
height: Schema.String,
mass: Schema.String,
birth_year: Schema.String,
})
type Person = typeof Person.Type

Schema.Struct parses untrusted JSON into a typed value. Effect.gen yields Schema.decodeUnknownEffect(Person)(json) directly; if the shape drifts on the server (Lucasfilm, please don’t), it’s a DecodeError in the return type, not a TypeError at runtime.


Exhaustive catchTags

const program = SwapiClient.getPerson("1").pipe(
Effect.catchTags({
FetchError: (e) => Effect.succeed(`Network: ${e.cause}`),
NotFoundError: (e) => Effect.succeed(`No such person: ${e.id}`),
JsonError: (e) => Effect.succeed(`Bad JSON: ${e.cause}`),
DecodeError: (e) => Effect.succeed(`Schema drift: ${e.cause}`),
}),
)

Remove any one branch and the compiler tells you the unhandled tag remains in E. Add a branch that doesn’t exist and the compiler tells you it never fires. This is the payoff for typing errors: the call site is forced to enumerate them.


ManagedRuntime for the test harness

import { ManagedRuntime, Layer } from "effect"
const TestLayer = Layer.mergeAll(SwapiClient.layer).pipe(
Layer.provide(HttpClientTest),
Layer.provide(TestConfig),
)
const runtime = ManagedRuntime.make(TestLayer)
const result = await runtime.runPromise(SwapiClient.getPerson("1"))

ManagedRuntime.make boots the layer graph once. Every runPromise reuses the same resolved services. In production, TestLayer becomes LiveLayer and HttpClientTest becomes HttpClientLive — the program in the middle doesn’t change.


Why SWAPI (and not Pokemon)

The TypeOnce course famously walks through this same construction with the Pokemon API. We use SWAPI deliberately — different domain, same shape of problem, so you can see the pattern rather than memorise the example. If you’ve done that course, this is the same machinery you already know; if you haven’t, you’ll find the Pokemon version a useful second reading.


The full test suite

The index.test.ts for this lesson, verbatim from steps/50a-swapi-api-client/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import {
Config,
ConfigProvider,
Context,
Data,
Effect,
Exit,
Layer,
ManagedRuntime,
Schema,
} from "effect"
const providerFromMap = (entries: Map<string, string>) =>
ConfigProvider.fromUnknown(Object.fromEntries(entries))
// =============================================================================
// 50a - Capstone: SWAPI API Client
// =============================================================================
//
// Composes: Context.Service with dependencies (23), Config + ConfigProvider (25),
// Data.TaggedError (04), Schema.Struct (18), Layer composition (22),
// ManagedRuntime (60), exhaustive catchTags (09).
//
// The function signature names every failure and every dependency:
//
// SwapiClient.getPerson: (id: string) =>
// Effect<Person, FetchError | NotFoundError | JsonError | DecodeError, SwapiClient>
// ---------- Errors ----------
class FetchError extends Data.TaggedError("FetchError")<{ readonly cause: unknown }> {}
class JsonError extends Data.TaggedError("JsonError")<{ readonly cause: unknown }> {}
class NotFoundError extends Data.TaggedError("NotFoundError")<{ readonly id: string }> {}
class DecodeError extends Data.TaggedError("DecodeError")<{ readonly cause: unknown }> {}
type SwapiError = FetchError | NotFoundError | JsonError | DecodeError
// ---------- Schema ----------
const Person = Schema.Struct({
name: Schema.String,
height: Schema.String,
mass: Schema.String,
birth_year: Schema.String,
})
type Person = typeof Person.Type
// ---------- HttpClient port (so tests don't hit the network) ----------
interface HttpResponse {
readonly status: number
readonly text: () => Promise<string>
}
class HttpClient extends Context.Service<HttpClient, { readonly get: (url: string) => Effect.Effect<HttpResponse, FetchError> }>()("HttpClient") {}
// ---------- Config service: reads SWAPI_BASE_URL ----------
class BuildSwapiUrl extends Context.Service<BuildSwapiUrl>()("BuildSwapiUrl", {
make: Effect.gen(function* () {
const base = yield* Config.string("SWAPI_BASE_URL")
return (path: string) => `${base.replace(/\/$/, "")}${path}`
}),
}) {
static readonly layer = Layer.effect(this, this.make)
}
// ---------- The client ----------
class SwapiClient extends Context.Service<SwapiClient>()("SwapiClient", {
make: Effect.gen(function* () {
const buildUrl = yield* BuildSwapiUrl
const http = yield* HttpClient
const getPerson = (id: string): Effect.Effect<Person, SwapiError> =>
Effect.gen(function* () {
const response = yield* http.get(buildUrl(`/people/${id}`))
if (response.status === 404) return yield* new NotFoundError({ id })
if (response.status >= 400)
return yield* new FetchError({ cause: `status ${response.status}` })
const body = yield* Effect.tryPromise({
try: () => response.text(),
catch: (cause) => new FetchError({ cause }),
})
const parsed = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: (cause) => new JsonError({ cause }),
})
return yield* Schema.decodeUnknownEffect(Person)(parsed).pipe(
Effect.mapError((cause) => new DecodeError({ cause })),
)
})
return { getPerson } as const
}),
}) {
static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(BuildSwapiUrl.layer))
}
// ---------- Test layers ----------
const TestConfig = ConfigProvider.layer(
providerFromMap(new Map([["SWAPI_BASE_URL", "https://swapi.info/api"]])),
)
const luke = {
name: "Luke Skywalker",
height: "172",
mass: "77",
birth_year: "19BBY",
}
function makeHttpStub(responses: Record<string, { status: number; body: string }>) {
return Layer.succeed(HttpClient, {
get: (url: string) =>
Effect.gen(function* () {
const r = responses[url]
if (!r) return yield* new FetchError({ cause: `no stub for ${url}` })
return { status: r.status, text: () => Promise.resolve(r.body) }
}),
})
}
const HappyHttp = makeHttpStub({
"https://swapi.info/api/people/1": { status: 200, body: JSON.stringify(luke) },
"https://swapi.info/api/people/999": { status: 404, body: "" },
"https://swapi.info/api/people/garbage": { status: 200, body: "not json {" },
"https://swapi.info/api/people/wrong-shape": {
status: 200,
body: JSON.stringify({ name: "Anakin" }),
},
"https://swapi.info/api/people/dead": { status: 502, body: "" },
})
// Merge BuildSwapiUrl.layer so beats that exercise it directly can resolve
// the service; SwapiClient.layer would otherwise hide it behind its own
// dependency graph.
const TestLayer = Layer.mergeAll(SwapiClient.layer, BuildSwapiUrl.layer).pipe(
Layer.provide(HappyHttp),
Layer.provide(TestConfig),
)
describe("50a - swapi api client", () => {
// @beat
// id: service-shape
// shape: read-run
// title: A service that owns its dependency graph
// teaches: Context.Service with `dependencies: [...]` auto-provides every required layer when you supply only SwapiClient.layer.
// hint: SwapiClient.layer already pulls in BuildSwapiUrl.layer
describe("service shape", () => {
it("getPerson returns the parsed Person on a 200", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const program = Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson("1")
})
const person = await runtime.runPromise(program)
expect(person.name).toBe("Luke Skywalker")
expect(person.birth_year).toBe("19BBY")
await runtime.dispose()
})
})
// @end-beat
// @beat
// id: config-injection
// shape: read-run
// title: providerFromMap replaces process.env in tests
// teaches: BuildSwapiUrl reads Config.string("SWAPI_BASE_URL") which is injected through ConfigProvider, not the environment.
// hint: ConfigProvider.layer(providerFromMap(...))
describe("config injection", () => {
it("uses the SWAPI_BASE_URL provided by the test ConfigProvider", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const program = Effect.gen(function* () {
const buildUrl = yield* BuildSwapiUrl
return buildUrl("/people/1")
})
const url = await runtime.runPromise(program)
expect(url).toBe("https://swapi.info/api/people/1")
await runtime.dispose()
})
})
// @end-beat
// @beat
// id: typed-errors
// shape: read-run
// title: Each failure mode is its own tagged class
// teaches: FetchError, NotFoundError, JsonError, DecodeError make every branch nameable in the type signature.
// hint: Exit.isFailure + Cause.findErrorOption
describe("typed errors", () => {
it("404 → NotFoundError", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const exit = await runtime.runPromiseExit(
Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson("999")
}),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = exit.cause.reasons[0]?._tag === "Fail" ? exit.cause.reasons[0].error : null
expect(err?._tag).toBe("NotFoundError")
expect(err && "id" in err ? err.id : null).toBe("999")
}
await runtime.dispose()
})
it("502 → FetchError", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const exit = await runtime.runPromiseExit(
Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson("dead")
}),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") {
expect((exit.cause.reasons[0].error as { _tag: string })._tag).toBe("FetchError")
}
await runtime.dispose()
})
})
// @end-beat
// @beat
// id: schema-decode
// shape: read-run
// title: Schema.Struct is the wire contract
// teaches: A response missing required fields is a DecodeError, not a runtime TypeError elsewhere in the app.
// hint: Schema.decodeUnknownEffect returns Effect with a ParseError that we map to DecodeError
describe("schema decode", () => {
it("wrong shape → DecodeError", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const exit = await runtime.runPromiseExit(
Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson("wrong-shape")
}),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") {
expect((exit.cause.reasons[0].error as { _tag: string })._tag).toBe("DecodeError")
}
await runtime.dispose()
})
it("non-JSON body → JsonError", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const exit = await runtime.runPromiseExit(
Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson("garbage")
}),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") {
expect((exit.cause.reasons[0].error as { _tag: string })._tag).toBe("JsonError")
}
await runtime.dispose()
})
})
// @end-beat
// @beat
// id: catch-tags-exhaustive
// shape: read-run
// title: catchTags forces you to handle every failure
// teaches: Removing any branch leaves that tag in E; adding an impossible branch is a compile error.
// hint: Effect.catchTags({ FetchError, NotFoundError, JsonError, DecodeError })
describe("catchTags exhaustive", () => {
it("recovers every failure mode into a human-readable string", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const describe1 = (id: string) =>
Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson(id)
}).pipe(
Effect.map(() => "ok"),
Effect.catchTags({
FetchError: (e) => Effect.succeed(`network: ${String(e.cause)}`),
NotFoundError: (e) => Effect.succeed(`missing: ${e.id}`),
JsonError: () => Effect.succeed("bad json"),
DecodeError: () => Effect.succeed("schema drift"),
}),
)
const [ok, missing, bad, drift, net] = await Promise.all([
runtime.runPromise(describe1("1")),
runtime.runPromise(describe1("999")),
runtime.runPromise(describe1("garbage")),
runtime.runPromise(describe1("wrong-shape")),
runtime.runPromise(describe1("dead")),
])
expect(ok).toBe("ok")
expect(missing).toBe("missing: 999")
expect(bad).toBe("bad json")
expect(drift).toBe("schema drift")
expect(net.startsWith("network:")).toBe(true)
await runtime.dispose()
})
})
// @end-beat
// @beat
// id: managed-runtime
// shape: read-run
// title: ManagedRuntime amortises the layer graph
// teaches: A single ManagedRuntime.make resolves SwapiClient once and reuses it across every runPromise — production wiring looks identical, just with a Live HttpClient.
// hint: ManagedRuntime.make(TestLayer); runtime.runPromise(...); runtime.dispose()
describe("managed runtime", () => {
it("reuses the same runtime across many calls", async () => {
const runtime = ManagedRuntime.make(TestLayer)
const calls = await Promise.all(
Array.from({ length: 5 }, () =>
runtime.runPromise(
Effect.gen(function* () {
const client = yield* SwapiClient
return yield* client.getPerson("1")
}),
),
),
)
expect(calls.every((p) => p.name === "Luke Skywalker")).toBe(true)
await runtime.dispose()
})
})
// @end-beat
})
← PreviousE-commerce CheckoutNext →Project Setup