Why Effect for Everything
Most TypeScript APIs are a patchwork. Express for routing, Zod for validation, some DI library, a separate error handling strategy, maybe tsyringe or manual wiring. Each piece works on its own, but they don’t compose. Errors leak across boundaries. Dependencies are invisible until runtime.
Effect takes a different approach. It gives you one mental model for everything: effects that track success types, error types, and dependencies in the type system. When you compose them, errors union automatically and requirements accumulate. Nothing is hidden.
This isn’t a framework comparison. This is what it looks like when you build an API using nothing but Effect.
The core abstraction is simple. Every operation returns Effect<Success, Error, Requirements>. Three type parameters that the compiler tracks through every composition. When two effects compose, their errors union and their requirements accumulate. You always know exactly what can go wrong and what you need to run it.
That changes how you think about building things.
Project Setup
npm install effect @effect/platform @effect/platform-node @effect/sql @effect/sql-pg
npm install -D @effect/vitest vitest pnpm add effect @effect/platform @effect/platform-node @effect/sql @effect/sql-pg
pnpm add -D @effect/vitest vitest bun add effect @effect/platform @effect/platform-node @effect/sql @effect/sql-pg
bun add -D @effect/vitest vitest src/ ├── api/ │ ├── routes.ts │ └── middleware.ts ├── domain/ │ ├── user.ts │ └── errors.ts ├── services/ │ ├── users.ts │ └── database.ts ├── config.ts ├── server.ts └── main.ts test/ └── users.test.ts
Domain Modeling with Schema
Forget Zod. Effect has Schema built in. It validates, encodes, decodes, and generates TypeScript types from a single definition. But the real power is Schema.Class, which gives you validated constructors and opaque types:
import { Schema } from "effect"
export class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.NonEmptyString,
email: Schema.String.pipe(Schema.pattern(/@/)),
createdAt: Schema.DateFromString,
}) {}
export class CreateUserInput extends Schema.Class<CreateUserInput>("CreateUserInput")({
name: Schema.NonEmptyString,
email: Schema.String.pipe(Schema.pattern(/@/)),
}) {}
The highlighted lines show where validation rules live. NonEmptyString rejects empty strings at the type level. The email pattern is checked at runtime. Both are defined once, in the same place as the type.
new User({ ... }) validates at construction. Schema.decodeUnknown(User) validates arbitrary input and returns an Effect with a typed parse error. No parse vs safeParse ambiguity. One model, one behavior.
Schema.Class creates both a runtime validator and a TypeScript type. You never write an interface and a schema separately. They are the same thing.
Typed Errors
Every error in your API should be a tagged class. Not a string, not Error, not unknown. A data type with a _tag discriminator that the compiler tracks:
import { Schema, HttpApiSchema } from "@effect/platform"
export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
"UserNotFound",
{ userId: Schema.Number },
HttpApiSchema.annotations({ status: 404 })
) {}
export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 401 })
) {}
export class ValidationFailed extends Schema.TaggedError<ValidationFailed>()(
"ValidationFailed",
{ field: Schema.String, reason: Schema.String },
HttpApiSchema.annotations({ status: 422 })
) {}
When you use these in an Effect.gen, the error channel of the return type is automatically UserNotFound | Unauthorized | ValidationFailed. You can catchTag any of them individually. The compiler tells you which errors you haven’t handled.
This is fundamentally different from throwing new Error("not found") and hoping someone catches it upstream. Every failure path is visible in the signature. If a function can fail with UserNotFound, the caller knows. If you add a new error type, every callsite that doesn’t handle it becomes a type error. The compiler becomes your safety net.
You can also catch errors selectively:
program.pipe(
Effect.catchTag("UserNotFound", (error) =>
Effect.succeed({ fallback: true, userId: error.userId })
)
// Unauthorized and ValidationFailed still propagate
)
Services and Layers
This is the part that takes the longest to click, but once it does, you won’t want to go back.
Effect’s dependency injection works at the type level. You define what a service does, use it anywhere, and provide the implementation later through Layers. The third type parameter of Effect<Success, Error, Requirements> tracks which services are needed. If you forget to provide one, the program doesn’t compile.
import { Context, Effect, Layer } from "effect"
import { SqlClient } from "@effect/sql"
import { User, CreateUserInput } from "../domain/user"
import { UserNotFound } from "../domain/errors"
export class UserService extends Context.Tag("UserService")<
UserService,
{
readonly findById: (id: number) => Effect.Effect<User, UserNotFound>
readonly create: (input: CreateUserInput) => Effect.Effect<User>
readonly list: () => Effect.Effect<ReadonlyArray<User>>
}
>() {}
export const UserServiceLive = Layer.effect(
UserService,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return {
findById: (id) =>
Effect.gen(function* () {
const rows = yield* sql`SELECT * FROM users WHERE id = ${id}`
if (rows.length === 0) {
return yield* Effect.fail(new UserNotFound({ userId: id }))
}
return yield* Schema.decodeUnknown(User)(rows[0])
}),
create: (input) =>
Effect.gen(function* () {
const rows = yield* sql`
INSERT INTO users (name, email, created_at)
VALUES (${input.name}, ${input.email}, NOW())
RETURNING *
`
return yield* Schema.decodeUnknown(User)(rows[0])
}),
list: () =>
Effect.gen(function* () {
const rows = yield* sql`SELECT * FROM users ORDER BY created_at DESC`
return yield* Effect.forEach(rows, (row) =>
Schema.decodeUnknown(User)(row)
)
}),
}
})
)
Look at the highlighted lines. yield* SqlClient.SqlClient pulls the database client from context. The service never imports a database module directly. It declares a need, and the Layer system resolves it at composition time.
The second highlight shows the error path. Effect.fail with a typed error that propagates to the caller. No throw, no try/catch. The type system does the work.
This is the key insight: swap the SQL layer for an in-memory version in tests and nothing else changes. The service code is identical regardless of what database is behind it.
Building the HTTP API
This is where it gets interesting. Most HTTP frameworks make you think in terms of request handlers. Effect makes you think in terms of contracts.
@effect/platform lets you define your API as a contract first, then implement it separately. The contract is the source of truth for routes, payloads, responses, and error codes. Handlers are just functions that satisfy the contract:
import {
HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema
} from "@effect/platform"
import { Schema } from "effect"
import { User, CreateUserInput } from "../domain/user"
import { UserNotFound, Unauthorized } from "../domain/errors"
import { Authentication } from "./middleware"
const idParam = HttpApiSchema.param("id", Schema.NumberFromString)
export class UsersGroup extends HttpApiGroup.make("users")
.add(
HttpApiEndpoint.get("list", "/")
.addSuccess(Schema.Array(User))
)
.add(
HttpApiEndpoint.get("findById")`/${idParam}`
.addSuccess(User)
.addError(UserNotFound)
)
.add(
HttpApiEndpoint.post("create", "/")
.setPayload(CreateUserInput)
.addSuccess(User)
)
.middleware(Authentication)
.prefix("/users")
{}
export class Api extends HttpApi.make("api")
.add(UsersGroup)
{}
Now implement the handlers. Each handler receives a typed request object with path, payload, headers already validated:
import { HttpApiBuilder } from "@effect/platform"
import { Layer } from "effect"
import { Api } from "./routes"
import { UserService } from "../services/users"
import { AuthenticationLive } from "./middleware"
export const UsersHandlers = HttpApiBuilder.group(Api, "users", (handlers) =>
handlers
.handle("list", () => {
const service = Effect.flatMap(UserService, (s) => s.list())
return service
})
.handle("findById", (_) =>
Effect.gen(function* () {
const service = yield* UserService
return yield* service.findById(_.path.id)
})
)
.handle("create", (_) =>
Effect.gen(function* () {
const service = yield* UserService
return yield* service.create(_.payload)
})
)
).pipe(Layer.provide(AuthenticationLive))
The handlers are pure functions. No req, no res, no next. Just data in, data out. Effect handles serialization, status codes, content negotiation, and error responses automatically based on your contract.
Think about what’s not here. No try/catch blocks. No res.status(404).json(...). No manual validation of req.params.id. The contract defines the shape, the handler does the work, and Effect bridges the gap. If the user sends id=abc, the 422 response happens before your handler code runs.
Authentication Middleware
If you’ve worked with Express middleware, you know the pattern: mutate req, call next(), pray that the types are right downstream. Effect does it differently.
Middleware is a service that provides context. It has a typed failure mode and a typed output. Handlers that need authentication declare it in their contract, and the type system enforces it:
import {
HttpApiMiddleware, HttpApiSecurity, HttpApiSchema
} from "@effect/platform"
import { Context, Effect, Layer, Redacted, Schema } from "effect"
import { User } from "../domain/user"
import { Unauthorized } from "../domain/errors"
export class CurrentUser extends Context.Tag("CurrentUser")<
CurrentUser,
User
>() {}
export class Authentication extends HttpApiMiddleware.Tag<Authentication>()(
"Authentication",
{
failure: Unauthorized,
provides: CurrentUser,
security: {
bearer: HttpApiSecurity.bearer,
},
}
) {}
export const AuthenticationLive = Layer.succeed(
Authentication,
Authentication.of({
bearer: (token) =>
Effect.gen(function* () {
const rawToken = Redacted.value(token)
if (!rawToken.startsWith("valid_")) {
return yield* Effect.fail(
new Unauthorized({ message: "Invalid token" })
)
}
return new User({
id: 1,
name: "Authenticated User",
email: "user@example.com",
createdAt: new Date().toISOString(),
})
}),
})
)
Any handler behind this middleware gets CurrentUser in its context. The type system guarantees it. If you forget to provide AuthenticationLive, the compiler tells you.
Database Layer
Here’s where you appreciate the Layer model. Your entire database setup, connection pooling, lifecycle management, all of it, lives in a single layer:
import { PgClient } from "@effect/sql-pg"
import { Config, Redacted, Layer } from "effect"
export const DatabaseLive = PgClient.layer({
url: Config.redacted("DATABASE_URL"),
})
That’s it. One line. The PgClient layer provides SqlClient.SqlClient which your UserServiceLive consumes. Connection pooling, prepared statements, and cleanup are handled internally.
Config.redacted reads environment variables and wraps them in Redacted. If you accidentally log the config, it prints <redacted> instead of your credentials. Security by default.
Configuration
import { Config, Schema } from "effect"
export const AppConfig = {
port: Config.number("PORT").pipe(Config.withDefault(3000)),
host: Config.string("HOST").pipe(Config.withDefault("0.0.0.0")),
databaseUrl: Config.redacted("DATABASE_URL"),
jwtSecret: Config.redacted("JWT_SECRET"),
allowedOrigins: Config.array(Config.string(), "ALLOWED_ORIGINS").pipe(
Config.withDefault(["*"])
),
}
Config reads from environment variables, validates the types, and fails at startup with a clear message if something is missing. Not at the first request. Not silently. Not with a undefined is not a function somewhere deep in a handler.
If you’ve ever deployed an app and realized ten minutes later that you forgot to set an env var, this alone justifies the setup cost.
Wiring It All Together
This is the final composition step. Every layer, every service, every middleware comes together in one place. The dependency graph resolves itself:
import {
HttpApiBuilder, HttpApiSwagger, HttpMiddleware, HttpServer
} from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { createServer } from "node:http"
import { Api } from "./api/routes"
import { UsersHandlers } from "./api/handlers"
import { UserServiceLive } from "./services/users"
import { DatabaseLive } from "./services/database"
const ApiLive = HttpApiBuilder.api(Api).pipe(
Layer.provide(UsersHandlers),
Layer.provide(UserServiceLive),
Layer.provide(DatabaseLive),
)
HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
Layer.provide(HttpApiSwagger.layer()),
Layer.provide(HttpApiBuilder.middlewareOpenApi()),
Layer.provide(HttpApiBuilder.middlewareCors()),
Layer.provide(ApiLive),
HttpServer.withLogAddress,
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
Layer.launch,
NodeRuntime.runMain,
)
timestamp=2025-03-15T10:00:00.000Z level=INFO message="Listening on http://0.0.0.0:3000" You get an HTTP server with logging, CORS, OpenAPI spec, and Swagger UI at /docs. Every route is type-safe, every error has a status code, every dependency is wired through Layers.
No glue code. No middleware chains. No manual error mapping. If any layer is missing, the program fails to compile. You cannot accidentally deploy a server that’s missing a database connection or an auth provider.
Read the highlighted Layer.provide chain. That’s your entire application architecture in three lines. UsersHandlers needs UserService. UserService needs SqlClient. DatabaseLive provides SqlClient. The compiler verifies the entire chain.
Testing
Testing is where the Layer architecture pays for itself. Every service is behind an interface. Swap the implementation and nothing else changes:
import { it, expect } from "@effect/vitest"
import { Effect, Exit, Layer } from "effect"
import { UserService, UserServiceLive } from "../src/services/users"
import { User, CreateUserInput } from "../src/domain/user"
const UserServiceTest = Layer.succeed(UserService, {
findById: (id) =>
id === 1
? Effect.succeed(new User({
id: 1,
name: "Test User",
email: "test@example.com",
createdAt: new Date().toISOString(),
}))
: Effect.fail(new UserNotFound({ userId: id })),
create: (input) =>
Effect.succeed(new User({
id: 42,
name: input.name,
email: input.email,
createdAt: new Date().toISOString(),
})),
list: () => Effect.succeed([]),
})
it.effect("finds an existing user", () =>
Effect.gen(function* () {
const service = yield* UserService
const user = yield* service.findById(1)
expect(user.name).toBe("Test User")
}).pipe(Effect.provide(UserServiceTest))
)
it.effect("fails for unknown user", () =>
Effect.gen(function* () {
const service = yield* UserService
const result = yield* Effect.exit(service.findById(999))
expect(Exit.isFailure(result)).toBe(true)
}).pipe(Effect.provide(UserServiceTest))
)
it.effect("creates a user", () =>
Effect.gen(function* () {
const service = yield* UserService
const user = yield* service.create(
new CreateUserInput({ name: "New User", email: "new@example.com" })
)
expect(user.id).toBe(42)
expect(user.name).toBe("New User")
}).pipe(Effect.provide(UserServiceTest))
)
No mocking libraries. No jest.mock. No sinon. You define a test layer with pure functions that satisfy the same interface, provide it, and run. The service code never knows the difference.
This is the architectural payoff of the Layer pattern. In a traditional codebase, testing means reaching into module internals, patching imports, or injecting through constructor args. Here, the boundary is the service interface. The implementation is a Layer. Swap it and move on.
Closing Thoughts
I spent years building APIs with Express, Fastify, NestJS. They all work. But they all have the same fundamental limitation: the type system stops at the boundary. Request validation is runtime-only. Errors are untyped. Dependencies are invisible.
Effect removes those boundaries. There is no point where you leave the type system. Errors, dependencies, configuration, validation, HTTP routing, database access, testing. Everything composes through the same abstraction. The compiler sees the whole picture.
The learning curve is real. Effect.gen, Layer, Schema.Class, tagged errors. It took me a couple of weeks of real usage before it clicked. But once it did, going back to try/catch and any felt like giving up information the compiler could have used.
This isn’t for every project. If you need a quick CRUD API for a hackathon, Express and Zod will get you there faster. But if you’re building something that needs to be correct, maintainable, and testable at scale, Effect is worth the investment. The upfront cost is higher. The long-term cost is dramatically lower.