Building Reliable Systems with Effect (TypeScript)
Over the past few months at Warp, I've been working with the Effect TypeScript library while building backend services and internal infrastructure.
Effect introduces a different way of thinking about programs. Instead of executing code immediately, you describe computations first, and then run them later. This approach leads to systems that are easier to reason about, easier to test, and much more explicit about failures and dependencies.
In this post, I want to explain what Effect is and highlight a few concepts from Effect that I've particularly enjoyed using in production systems.
What is Effect?
Effect is a TypeScript library designed to help engineers build reliable, composable, and type-safe systems.
Instead of running code directly, you describe a computation first, which is called an Effect.
Effect<A, E, R>Where:
A→ success valueE→ possible errorR→ required dependencies (environment)
In other words, an Effect represents: a computation that may fail with E, succeed with A, and requires environment R.
Normal Async Code vs Effect
A typical async function in JavaScript might look like this:
async function getUser(id: string) {
const res = await fetch(`/api/user/${id}`)
return res.json()
}There are a few problems here:
- Errors are untyped
- The function runs immediately
- It's harder to compose with other logic
With Effect, we instead describe the computation.
import { Effect } from "effect"
const getUser = (id: string) =>
Effect.promise(() => fetch(`/api/user/${id}`))Notice something important: getUser does not run yet. It simply returns a description of the computation. We execute it later with:
Effect.runPromise(getUser("123"))This separation between describing work and executing work is a core idea in Effect, and makes complex systems significantly easier to compose.
Effect Schema: Reliable API Contracts
One of my favorite parts of Effect is Effect Schema. Schemas allow you to define data contracts that are type-safe during development and validated at runtime.
This is especially important for client-server communication, where assumptions frequently break. TypeScript types only exist at compile time. Once data crosses a network boundary, those guarantees disappear.
Effect Schema solves this by making the data shape something you can decode, validate, and transform at runtime. Instead of writing TypeScript interfaces for the frontend, validation logic on the backend, manual checks for API responses, and ad-hoc transformation code, you define a single shared schema.
import { Schema as S } from "effect"
export const User = S.Struct({
id: S.Number,
name: S.String,
email: S.String,
createdAt: S.DateFromString
})
export type User = typeof User.TypeThis schema now acts as both the TypeScript type and the runtime validator. On the backend, before returning data from the server, we encode it using the schema:
import { Schema as S } from "effect"
import { User } from "./schemas"
const newUser = {
id: 1,
name: "Lawrence",
email: "lawrence@gmail.com",
createdAt: new Date()
}
const encoded = S.encodeSync(User)(newUser)
return Response.json(encoded)This ensures the API always returns data that conforms to the schema contract. On the client, we decode the response using the same schema:
import { Schema as S } from "effect"
import { User } from "./schemas"
const res = await fetch("/api/user")
const json = await res.json()
const user = S.decodeSync(User)(json)If the backend sends something unexpected, the decode step will fail immediately. This prevents subtle bugs where invalid data silently propagates through the system.
Typed Error Handling
Another reason many teams adopt Effect is its explicit error modeling. Instead of throwing exceptions or returning loosely structured error objects, services can declare exactly which errors are possible. For example: Unauthorized, UserNotFound, ValidationError. These become part of the API contract.
Defining a tagged error ties together a typed error, a schema, and an HTTP status mapping:
import { Schema as S } from "effect"
import { HttpApiGroup, HttpApiSchema } from "effect/platform"
export class UserNotFound extends S.TaggedError<UserNotFound>()(
"UserNotFound",
{
message: S.String
},
HttpApiSchema.annotations({ status: 404 })
)We can then attach this error to an endpoint, so the contract explicitly advertises both its success response and every possible failure:
const UserSchema = S.Struct({
id: S.Number,
name: S.String,
email: S.String,
createdAt: S.DateFromString
})
export const UserGroup = HttpApiGroup.make("user")
.add(
HttpApiEndpoint.get("get", "/id")
.addSuccess(UserSchema)
.addError(UserNotFound)
)This also makes testing significantly more robust. Without typed errors, tests often rely on fragile string checks:
expect(error.message).toContain("User is unauthorized")With Effect, you can assert the actual error type:
expect(error).toBeInstanceOf(UserNotFound)This removes ambiguity and makes tests far more robust.
Why I Enjoy Using Effect
As someone who is relatively new to building important services in production, Effect has helped me write systems that are much more reliable.
Instead of hoping things work at runtime, Effect encourages you to model the system explicitly. That shift in thinking has made a big difference in how I approach backend engineering.