Track business logic failure paths with types
If you look inside catch (e: unknown) blocks in production code, there's a repeating pattern: cast e to any, log it, and move on. Even with TypeScript, the error path behaves like a dynamically typed language.
The problem is that as business domains grow more complex, "what errors can occur" is nowhere visible in the function signature. Whether UserService.createUser() throws a ValidationError, DuplicateEmailError, or DatabaseError — you have to dig through comments or open the source to find out. As teams grow and codebases expand, this hidden knowledge turns into bugs.
Railway-Oriented Programming (ROP) solves this problem by flipping the idea entirely. Instead of throwing errors as exceptions, it carries errors in the return type. And Effect-TS is the most practical implementation of that concept in the TypeScript ecosystem. This article walks through the core principles of ROP and the process of composing complex order-processing logic in a type-safe way with Effect-TS.
What is Railway-Oriented Programming?
This pattern, systematized by Scott Wlaschin in the F# community, is easiest to understand by imagining a railway with two tracks — just as the name suggests.
Values are transformed step by step along the happy path (success track), and if a failure occurs at any step, execution switches to the error track and skips all subsequent steps. The key is that errors flow as return values rather than escaping via throw.
// Traditional approach: errors are hidden
async function processOrder(userId: string): Promise<Order> {
// No way to tell from the type what this function can throw
}
// ROP approach: failure paths are visible in the type
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
function processOrder(userId: string): Result<Order, UserNotFound | InsufficientStock>
// The function signature alone tells you "what can go wrong"★ Insight ─────────────────────────────────────
- ROP makes errors first-class citizens of the type system.
throwescapes the type system, but returning aResultlets the compiler track it. - The key insight is "failures are data too." Handling errors as exceptions hides control flow, but handling them as values enables composition.
─────────────────────────────────────────────────
Why Plain Result<T, E> Isn't Enough
Libraries like neverthrow let you encode errors in types with Result<T, E>. But real-world business logic has three additional problems.
First, async chaining is painful. When each step returns Promise<Result<T, E>>, you need to await and branch on .ok repeatedly just to extract the success value. Second, there's no dependency tracking. The type carries no information that this function requires a DatabaseService to run. Third, there's no structured concurrency. Safely cleaning up remaining resources when one parallel operation fails becomes manual work.
Effect-TS addresses all three problems with a single type.
Effect-TS's Three Type Parameters
The core type in Effect-TS (package: effect) extends Result<T, E> one step further.
Effect<Success, Error, Requirements>
// ↑success ↑error union ↑services needed to runThe third parameter, Requirements, is unique. It encodes which services must be injected to run the Effect. The type system itself acts as a DI container.
import { Effect, Context, Layer, Data } from "effect"
// Define error types (covered in detail below)
class UserNotFound extends Data.TaggedError("UserNotFound")<{ userId: string }> {}
// Define the service interface as a Tag
class UserRepo extends Context.Tag("UserRepo")<
UserRepo,
{ findById: (id: string) => Promise<User | null> }
>() {}
// An Effect that uses all three type parameters
const findUser = (userId: string): Effect.Effect<User, UserNotFound, UserRepo> =>
Effect.gen(function* () {
const repo = yield* UserRepo // Pull from Requirements
const raw = yield* Effect.promise(() => repo.findById(userId))
if (!raw) return yield* Effect.fail(new UserNotFound({ userId }))
return raw
})
// Provide the real implementation via a Layer
const UserRepoLive = Layer.succeed(UserRepo, {
findById: (id) => db.users.findUnique({ where: { id } })
})
// Requirements must be satisfied via provide() before execution
const program = findUser("u-1").pipe(Effect.provide(UserRepoLive))
// program type: Effect<User, UserNotFound, never>
// ↑ Requirements is never → ready to run
await Effect.runPromise(program)With async/await, both which services you depend on and which errors you can throw disappear from the type. Effect keeps all of that information in the type.
★ Insight ─────────────────────────────────────
- An Effect whose
Requirementsis notnevercannot be passed torunPromise. If you haven't satisfied dependencies withprovide(), you get a compile error. This enforcement catches "missing service" issues at build time, not at runtime. - Services defined with
Context.Tagcan be trivially swapped in tests withLayer.succeed(UserRepo, mockImpl). No separate DI framework needed — the type system tracks the dependency graph.─────────────────────────────────────────────────
Tagged Error — Identity for Errors
For ROP's error track branching to work correctly, each error must be identifiable at runtime as well. Effect-TS solves this with Data.TaggedError.
import { Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: string
}> {}
class InsufficientStock extends Data.TaggedError("InsufficientStock")<{
itemId: string
requested: number
available: number
}> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{
reason: string
}> {}A _tag field is automatically assigned, so compile-time branching (catchTag) and runtime branching (switch) operate on the same basis. A single error type definition becomes the single source of truth for classification, handling, and serialization.
Unhandled cases remain in the error channel type. Adding an exhaustive check to catchAll lets you catch these omissions at compile time (demonstrated with code in the section below).
Effect.gen — Effect Code That Reads Like async/await
When you first encounter Effect, the pipe-chaining style can feel unfamiliar. Using the generator syntax of Effect.gen makes the code read almost identically to async/await, while all errors remain tracked in the type.
The code below shows the type signatures of each function first, then an example of composing them.
import { Effect } from "effect"
// ── Type signatures (real implementations live in their respective domain modules) ──
// findUser: (id: string) => Effect.Effect<User, UserNotFound>
// loadCart: (id: string) => Effect.Effect<Cart, CartNotFound>
// reserveStock: (cart: Cart) => Effect.Effect<Reservation, InsufficientStock>
// chargeUser: (user: User, reservation: Reservation) => Effect.Effect<Payment, PaymentDeclined>
// createOrder: (user: User, reservation: Reservation, payment: Payment) => Effect.Effect<Order, never>
// ── Composition ───────────────────────────────────────────────────────────────────
const processOrder = (userId: string, cartId: string) =>
Effect.gen(function* () {
// yield* runs an Effect and unwraps the success value
// On failure, immediately switches to the error track — no subsequent code runs
const user = yield* findUser(userId)
const cart = yield* loadCart(cartId)
const reserved = yield* reserveStock(cart)
const payment = yield* chargeUser(user, reserved)
return yield* createOrder(user, reserved, payment)
})
// Return type (inferred automatically by TypeScript):
// Effect<Order, UserNotFound | CartNotFound | InsufficientStock | PaymentDeclined, never>
// ↑ All possible errors are automatically unioned into the type★ Insight ─────────────────────────────────────
yield*on an Effect unwraps only the success value; errors flow automatically into the error channel. This is the same principle asawaitunwrapping aPromise.- Each function's error types are automatically unioned together, so adding or removing a step updates the error type automatically. No need to maintain the union manually.
─────────────────────────────────────────────────
Handling Error Track Branches with catchTag
processOrder has four error types in its return type. The HTTP handler layer handles them incrementally.
import { Effect } from "effect"
// Domain layer: handle some errors, leave the rest in the type
const processOrderWithApiErrors = (userId: string, cartId: string) =>
processOrder(userId, cartId).pipe(
// Handle UserNotFound → removed from the error type
Effect.catchTag("UserNotFound", (e) =>
Effect.fail(new ApiError({ status: 404, message: `User ${e.userId} not found` }))
),
Effect.catchTag("PaymentDeclined", (e) =>
Effect.fail(new ApiError({ status: 402, message: e.reason }))
)
// Return type: Effect<Order, ApiError | InsufficientStock | CartNotFound, never>
// Unhandled errors remain in the type
)// HTTP handler layer: catchAll + exhaustive check handles all errors
const handler = processOrderWithApiErrors(userId, cartId).pipe(
Effect.catchAll((error) => {
switch (error._tag) {
case "ApiError":
return Effect.succeed({ status: error.status, body: error.message })
case "InsufficientStock":
return Effect.succeed({ status: 409, body: "Insufficient stock" })
case "CartNotFound":
return Effect.succeed({ status: 404, body: "Cart not found" })
default: {
// A compile error here means there's an unhandled case
const _exhaustive: never = error
return _exhaustive
}
}
})
)
// Return type: Effect<{ status: number; body: string }, never, never>
// Error channel is never → compile-time guarantee that all errors are handledThe _exhaustive: never = error assignment enforces an exhaustive check using only TypeScript syntax. If a case is missing from the switch, error remains a non-never type and the compiler emits an error.
Input Validation Combined with Schema
Effect ships with a built-in Schema module, so runtime validation failures flow into the Effect error channel and are handled in the same pipeline.
import { Schema, Effect } from "effect"
const CreateOrderSchema = Schema.Struct({
userId: Schema.String,
cartId: Schema.String,
items: Schema.Array(
Schema.Struct({
id: Schema.String,
qty: Schema.Number.pipe(Schema.positive()) // Schema.Positive doesn't exist — use this instead
})
)
})
type CreateOrderDTO = Schema.Schema.Type<typeof CreateOrderSchema>
// Validate raw input and return as Effect
const parseBody = (raw: unknown) =>
Schema.decodeUnknown(CreateOrderSchema)(raw)
// Returns: Effect<CreateOrderDTO, ParseError, never>
// Connects naturally in an HTTP handler
const handleRequest = (rawBody: unknown) =>
Effect.gen(function* () {
const dto = yield* parseBody(rawBody) // ParseError
const order = yield* processOrder(dto.userId, dto.cartId) // domain errors
return order
}).pipe(
Effect.catchTag("ParseError", () =>
Effect.fail(new ApiError({ status: 400, message: "Invalid request format" }))
)
// Continue chaining domain error handling...
)Running an Effect
Composing a pipeline and executing it are separate concerns. A composed Effect is merely a description; actual execution happens via Effect.runPromise (or Effect.runSync when synchronous execution is needed).
// Composing the pipeline — nothing runs yet
const program = handleRequest(req.body).pipe(
Effect.provide(UserRepoLive)
)
// Execution — asynchronous work begins here
const result = await Effect.runPromise(program)
// ↑ Error channel must be never to compile
// Remaining errors are converted to runtime exceptions
// Use runPromiseExit if you want to handle errors directly
const exit = await Effect.runPromiseExit(program)
// Exit<SuccessValue, Error> — handle both success and failure explicitlyEffect.runPromise does not emit a compile error if the error channel still contains a non-never type. Instead, unhandled errors are converted to runtime exceptions. This is why the _exhaustive: never = error pattern matters — you need a build-time guarantee that catchAll handles every case, or you'll face runtime surprises.
Incrementally Integrating Legacy Promise Code
Migrating an existing codebase all at once is impractical. Effect.tryPromise lets you wrap existing async functions as Effects and expand the boundary incrementally.
// Existing legacy code — unchanged
async function legacyFindUser(id: string): Promise<User | null> {
return db.users.findById(id)
}
// Wrap at the Effect boundary
const findUser = (userId: string): Effect.Effect<User, UserNotFound> =>
Effect.tryPromise({
try: async () => {
const user = await legacyFindUser(userId)
if (!user) throw new Error("not found")
return user
},
catch: () => new UserNotFound({ userId })
})
// findUser can now be used in Effect pipelinesAttempting a full migration all at once creates significant team resistance and risk. Gradually expanding the Promise → Effect boundary is the realistic approach. Starting with the module that has the most error variants in your domain tends to show results quickly.
Pros and Cons
Where Effect-TS Shines
| Advantage | Description |
|---|---|
| Compile-time error completeness | catchAll + exhaustive check catches unhandled error cases at build time. |
| Failure paths are public API | The function signature alone tells you "what can go wrong." No comments needed. |
| Structured concurrency built in | Effect.all, Effect.race, and fiber-based cancellation are provided out of the box. Resource cleanup omissions are resolved at the type level. |
| Single framework integration | DI container, logger, and OpenTelemetry tracing all connect through Effect alone. |
| Smaller bundle size in Effect v4 | The ~70 kB bundle in v3 dropped to ~20 kB in the v4 beta, with a simpler package structure. |
Points to Watch Out For
| Disadvantage | Description |
|---|---|
| Steep learning curve | Generator syntax, Tag-based DI, Layer composition, and the fiber model are unfamiliar paradigms for teams used to Express/Next.js. |
| Over-engineering risk | Introducing Effect into a simple CRUD API actually increases code volume. The Harbor team publicly shared a case where they chose not to adopt Effect for this reason. |
| Ecosystem compatibility cost | Most Node.js libraries are Promise-based. An Effect.tryPromise wrapping layer is required, and boundary design must be carefully considered. |
| v3 → v4 migration | Breaking changes are substantial, including Schema API changes and package consolidation. Review the migration guide before introducing it in production. |
Choosing the Right Tool
| Situation | Recommendation |
|---|---|
| Learning ROP concepts, small utilities | neverthrow or ts-railway |
| Mid-to-large backend, complex domain logic | effect |
Maintaining a legacy fp-ts codebase |
Keep fp-ts + start new modules with effect |
| AI pipelines, need for structured concurrency | effect (includes built-in OpenTelemetry tracing) |
Common Mistakes in Practice
Handling errors too early is the most common one. If you collapse all errors into a single Error at the domain layer, the layers above lose the ability to handle errors selectively by type.
// Pattern to avoid: collapsing errors too early in the domain layer
const badPattern = findUser(id).pipe(
Effect.catchAll(() => Effect.fail(new Error("something went wrong")))
// Callers can no longer distinguish the error type — is it UserNotFound or DbError?
)
// Recommended pattern: preserve error types and pass them up to the HTTP layer
const goodPattern = (id: string) =>
findUser(id).pipe(
// No catchAll — preserve the UserNotFound type as-is
Effect.map((user) => ({ ...user, processedAt: Date.now() }))
// The HTTP handler does the final conversion with catchTag("UserNotFound", e => ...)
)
// Return type: Effect<{ ...User; processedAt: number }, UserNotFound, never>
// Error information is preserved so callers can handle it however they needKeep errors as specific types as possible all the way up to just before the HTTP layer.
Closing Thoughts
The core of handling error flow with Effect-TS comes down to this:
try/catch sends errors outside the type system; Effect keeps errors flowing as data inside the type system. As a result, "what can go wrong with this function" is declared in the function signature, and unhandled error cases are caught at compile time. In complex business domains, this difference is felt acutely.
Effect is not the right answer for every project. For simple CRUD APIs, it's overkill. But for domains where multiple error types intersect — payments, inventory, user authentication — and if the team is willing to invest in the learning curve — it's worth serious consideration.
If you want to start today, here are three recommended steps.
- Get a feel for ROP with
neverthrow— lightly experience theResult<T, E>pattern and see what it feels like to "treat errors as values." - Start with the error-handling section of the Effect tutorial — the official docs' Expected Errors section is well-organized. Type out small examples of
Data.TaggedErrorandcatchTagyourself. - Apply
Effect.tryPromiseto one spot in your legacy code — wrap one existing Promise function as an Effect and feel how the type inference changes.
★ Insight ─────────────────────────────────────
- Effect v4 beta (April 2026) reduced the bundle size from 70 kB to ~20 kB and consolidated packages like
@effect/platforminto the main package. If you're considering a production adoption, check the v4 migration guide first. - With
fp-tsauthor Giulio Canti joining the Effect organization, Effect has effectively become the successor tofp-ts. For new projects, choosing Effect is the better long-term bet.─────────────────────────────────────────────────
References
- Effect Official Docs — Introduction
- Effect Official Docs — Expected Errors
- Effect Official Docs — Yieldable Errors
- Effect v4 Beta Release Notes
- Effect v4 Beta — InfoQ Analysis
- Effect vs neverthrow Official Comparison
- Effect vs fp-ts Official Comparison
- Railway Oriented Programming — F# for fun and profit (original)
- Railway Oriented Programming in TypeScript — DEV Community
- Why We Love FP but Don't Use Effect-TS — Harbor Team's Counterpoint
- neverthrow vs Effect vs oxide.ts 2026 Comparison — PkgPulse
- Effect-TS GitHub Repository
- EffectPatterns — Community Pattern Collection