50a - Capstone: SWAPI 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 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
HttpClientservice abstractsfetchso tests can swap it without touching the network. BuildSwapiUrlreads the base URL fromConfig— production gets it fromprocess.env, tests inject it viaConfigProvider.fromUnknown.SwapiClient.getPerson(id)returnsEffect<Person, FetchError | JsonError | DecodeError | NotFoundError, …>. The return type is the spec.- Each error gets its own
Data.TaggedErrorsoEffect.catchTags({...})is exhaustive — the compiler refuses to let you ignore a branch. - A
ManagedRuntimecomposesSwapiClient.layer(which pulls inBuildSwapiUrl.layerviadependencies) so the test harness boots the whole graph once and reuses it across everyit(...).
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 5xxNotFoundError— server responded 404JsonError— body wasn’t valid JSONDecodeError— JSON didn’t matchSchema.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.TypeSchema.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/.
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})