Typed Errors in TypeScript: The Options, the Tradeoffs, and the Missing Middle
TypeScript models success as data. It does not model failure as data.

When a function throws, the error channel disappears from the type system. The caller gets unknown in a catch block. There's no equivalent to Java's throws clause. No way for the compiler to say:
This function can fail in these specific ways.
That's deliberate. Anders Hejlsberg has explained why checked exceptions create coupling and refactoring pain. TypeScript chose not to encode failure into the type system.
That tradeoff works until you start building pipelines.
Validation. Normalization. Business rules. External lookups. Enrichment.
At every stage, the type system tracks what flows forward on success.
On failure, it goes dark.
The error channel becomes invisible.
This article walks through the different ways to make that channel visible in TypeScript, what each approach costs, and where the library I built fits on that spectrum.
The Status Quo: Throw and Catch
Here's a typical pipeline:
async function processTransaction(raw: unknown):Promise<ProcessedTransaction> {
const validated = validateTransaction(raw);
const normalized = normalizeTransaction(validated);
assertBusinessRules(normalized);
try {
return await enrichWithCustomerData(normalized);
} catch {
return makePartialResult(normalized);
}
}
This works.
It's readable. It's idiomatic. Most production TypeScript looks like this.
The friction shows up later.
1) The call site can't see failure modes
The signature says:
Promise<ProcessedTransaction>
It does not say:
This might throw
ValidationErrorOr
BusinessRuleErrorOr an enrichment failure
The only way to distinguish them is runtime narrowing on unknown. The type system can't help. If another team owns the caller, they must read your implementation to understand what can go wrong.
The type signature doesn't describe the failure surface.
2) Batch processing collapses error structure
Wrap this in a loop and every failure becomes "something threw." You lose the distinction between validation failures and business rule violations unless you manually re-encode it.
Switching from "keep the valid ones" to "fail the whole batch" means rewriting control flow, not flipping a strategy.
3) Recovery logic is invisible
This line:
return makePartialResult(normalized);
is a business decision inside a catch. The caller sees only ProcessedTransaction. Whether it's partial or complete isn't reflected in the type.
None of this is broken.
But in multi-stage pipelines, especially batch systems, the cost becomes visible.
Level 1: Discriminated Unions (Zero Dependencies)
The simplest typed-error approach is one you already know:
type ProcessResult =
| { type: "success"; data: EnrichedTransaction }
| { type: "validation_error"; errors: string[] }
| { type: "business_rule"; reason: string }
| { type: "partial"; data: PartialTransaction };
async function processTransaction(raw: unknown): Promise<ProcessResult> {
const validation = validateTransaction(raw);
if (validation.type === "validation_error") return validation;
const normalized = normalizeTransaction(validation.data);
const ruleCheck = checkBusinessRules(normalized);
if (ruleCheck.type === "business_rule") return ruleCheck;
const enrichment = await enrichWithCustomerData(normalized);
if (enrichment.type === "success") return enrichment;
return { type: "partial", data: makePartialResult(normalized) };
}
This is a real improvement.
The error channel is visible in the type. The compiler forces narrowing. There's no dependency and every TypeScript developer understands it.
The tradeoffs:
The union is per-pipeline, not per-stage.
Composition is manual (
if (...) return ...everywhere).Batch semantics are DIY per union type.
For one or two pipelines, this is probably the right solution.
When you have five pipelines and each grows to six stages, the repetition starts to show.
Level 2: A Standardized Result Type (neverthrow)
neverthrow standardizes the discriminated union into Result<T, E> with helpers like map, andThen, and match:
import { ok, err } from "neverthrow";
const result = validateRaw(input)
.andThen(normalize)
.andThen(applyBusinessRules);
This removes the short-circuit boilerplate. Errors propagate automatically.
neverthrow gives you:
A consistent
ResultabstractionClean chaining
A small conceptual footprint
What it intentionally does not give you:
A pipeline composition model
Batch semantics across arrays of results
A schema layer
Cross-cutting tracing hooks
neverthrow is a Result type.
It standardizes how one function communicates success or failure.
It does not define how functions compose into reusable pipeline values.
That distinction matters once pipelines become architectural patterns instead of one-offs.
Level 3: Result + Option + Composition + Schema (@railway-ts/pipelines)
This is the missing middle I kept running into.
@railway-ts/pipelines keeps the same Result<T, E> idea but adds:
Point-free composition (
flow,flowAsync)Batch semantics (
partition,combine,combineAll)An
OptiontypeA schema module integrated directly into the error track
The same pipeline becomes:
import { flowAsync } from "@railway-ts/pipelines/composition";
import { ok, err, flatMapWith, orElseWith } from "@railway-ts/pipelines/result";
const processTransaction = flowAsync(
validateRaw,
flatMapWith(normalize),
flatMapWith(applyBusinessRules),
flatMapWith(enrichWithCustomerData),
orElseWith(recoverPartial),
);
Instead of chaining off a starting value, flowAsync produces a pipeline as a first-class function.
That means you can:
Pass it around
Wrap it with tracing
Test it independently
Swap recovery strategies
The pipeline itself becomes a value.
Batch Semantics as a Strategy
Given:
const results = await Promise.all(records.map((r) => processTransaction(r)));
You can choose interpretation:
import { partition, combine, combineAll } from "@railway-ts/pipelines/result";
const { successes, failures } = partition(results);
const allOrNothing = combine(results);
const allErrors = combineAll(results);
Switching behavior is a one-line change. No control-flow rewrite.
Why Schema Belongs Here
If the goal is to make the error channel visible, validation is the first place it appears.
Most pipelines start by turning unknown into typed data. That transformation produces:
A parsed value
Structured validation errors
If validation lives outside your error abstraction, the model breaks immediately.
That's why the schema module is included—not to compete with standalone validators, but to ensure validation failures share the same typed error track as business rule and enrichment failures.
The module is benchmarked on schemabenchmarks.dev and fully tree-shakable.
Schema isn't an add-on. It's the front door to the pipeline.
Tracing Without Modifying Stages
Once a pipeline is a composed value, observability becomes compositional too.
tapWith and tapErrWith observe values on either track without changing results. A traced pipeline and an untraced pipeline produce identical output for identical input.
Instrumentation becomes insertion, not intrusion. The ETL demo visualizes this — each record's stage trace shows exactly where it succeeded, where it failed, and how long each stage took, all captured by taps woven into the pipeline.
What It Costs
A dependency
New primitives (
flowAsync,flatMapWith)A more declarative style
The imperative version is immediately readable.
The composed version requires learning a protocol.
Whether that's worth it depends on:
How many pipelines you have
How complex your batch logic is
Whether your team values explicit error types enough to learn new abstractions
Level 4: Effect-TS
Effect-TS goes further than any of the above.
An Effect<A, E, R> models:
Success
Typed errors
Required dependencies
Concurrency
Resource safety
Scheduling
Effect is not just a container for success or failure. It's a full runtime and programming model.
Adopting it means adopting:
A fiber-based concurrency system
A layer-based dependency model
An execution boundary (
runPromise, etc.)
That's a framework-level commitment.
If your team wants typed errors as part of a broader architectural system, Effect is powerful.
If your problem is simply:
I have multi-stage data pipelines and I want failures to compose as data.
A lightweight Result + composition layer may be sufficient.
The difference isn't capability.
It's scope and commitment.
When to Use What
Throw and catch
When failure handling happens at boundaries and you don't need programmatic distinction.
Discriminated unions
When you have a small number of pipelines and want zero dependencies.
neverthrow
When you want a standardized Result with clean chaining.
@railway-ts/pipelines
When pipelines are recurring architecture, batch semantics matter, and validation should integrate with composition.
Effect-TS
When typed errors are part of a larger concurrency and dependency story.
The Missing Middle
There's a spectrum in TypeScript:
Exceptions at the boundary
Hand-rolled unions
A standalone
ResultA full effect system
What I kept hitting in production was a gap in the middle.
A way to:
Treat failures as data
Compose multi-stage pipelines without boilerplate
Switch batch strategies without rewriting loops
Drive validation and forms from one definition
Add tracing without modifying business logic
Without committing to an entire programming model.
That's the niche @railway-ts/pipelines is designed for.
If you've written the same validation → normalization → rule-check pipeline five times in one codebase, you already know the problem this is solving.
There's an interactive ETL demo you can run in the browser. Edit records, switch between partition, combine, and combineAll, and watch how the same data produces different batch outcomes.
If it feels wrong, awkward, or incomplete, I'd genuinely like to hear why. The sharpest feedback comes from engineers who've lived through these tradeoffs.



