Skip to main content

Command Palette

Search for a command to run...

Typed Errors in TypeScript: The Options, the Tradeoffs, and the Missing Middle

TypeScript models success as data. It does not model failure as data.

Updated
8 min read
Typed Errors in TypeScript: The Options, the Tradeoffs, and the Missing Middle
S
I work at the intersection of astrodynamics and software engineering - building spacecraft operations software at True Anomaly and maintaining open-source tooling (@railway-ts/pipelines, @railway-ts/use-form). Rust, TypeScript, React, functional patterns. Domain is space domain awareness and proximity operations.

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 ValidationError

  • Or BusinessRuleError

  • Or 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 Result abstraction

  • Clean 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 Option type

  • A 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 Result

  • A 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.