DEV Community

Cover image for Composable Async Pipelines in TypeScript: One Result Type, Zero Adapters
Sarkis M
Sarkis M

Posted on

Composable Async Pipelines in TypeScript: One Result Type, Zero Adapters

This is Part 2 of Railway-Oriented TypeScript. Part 1 counted the glue code tax in forms. The same pattern appears on the backend — and this is where @railway-ts/pipelines lives.

If you've ever used Zod on the backend, you know this code. It's in every route that validates input — the bridge between Zod's SafeParseReturnType and whatever Result type your pipeline expects:

// Zod returns SafeParseReturnType. Your pipeline expects Result.
const parsed = orderSchema.safeParse(body);
if (!parsed.success) {
  return err({
    type: "validation" as const,
    issues: parsed.error.issues.map((i) => ({
      path: i.path.map(String),
      message: i.message,
    })),
  });
}
// Now you can use parsed.data
Enter fullscreen mode Exit fullscreen mode

Every pipeline that starts with Zod validation has this bridge. You write it once, extract it into a utility, and move on — but the seam is structural. Your validation layer and your error-handling layer speak different languages.

What if validation just returned Result natively?

import {
  validate,
  object,
  required,
  chain,
  string,
  email,
  minLength,
} from "@railway-ts/pipelines/schema";
import { match } from "@railway-ts/pipelines/result";

const userSchema = object({
  email: required(chain(string(), email())),
  password: required(chain(string(), minLength(8))),
});

const result = validate(input, userSchema);
// Result<{ email: string; password: string }, ValidationError[]>
// No conversion. Same Result your pipeline steps consume.
Enter fullscreen mode Exit fullscreen mode

That's the premise of @railway-ts/pipelines. This part covers how it works.


Curried Helpers: The Design That Enables Composition

Method chaining (neverthrow's .andThen().map()) works, but it ties operations to Result instances and can't produce reusable, deferred pipelines. This library uses curried operators instead — functions that return functions:

// Without curried helpers — lambda noise at every step
const result = pipe(
  ok(5),
  (r) => map(r, (x) => x * 2),
  (r) => flatMap(r, (x) => divide(x, 3)),
);

// With curried helpers — point-free
const result = pipe(
  ok(5),
  mapWith((x) => x * 2),
  flatMapWith((x) => divide(x, 3)),
);
Enter fullscreen mode Exit fullscreen mode

Every Result operator has a curried counterpart: mapWith, flatMapWith, filterWith, tapWith, orElseWith, mapErrWith, tapErrWith. These are what make pipe and flow composition clean.

pipe executes immediately. flow returns a reusable function. pipeAsync and flowAsync do the same for async operations, awaiting each step and mixing sync and async freely.


A Minimal Pipeline

Before the full operator set, here's the basic shape — validate, transform, branch:

import { flowAsync } from "@railway-ts/pipelines/composition";
import { mapWith, match } from "@railway-ts/pipelines/result";
import {
  validate,
  object,
  required,
  chain,
  string,
  email,
  parseNumber,
  min,
} from "@railway-ts/pipelines/schema";

const contactSchema = object({
  email: required(chain(string(), email())),
  age: required(chain(parseNumber(), min(18))),
});

const processContact = flowAsync(
  (input: unknown) => validate(input, contactSchema),
  mapWith(({ email, age }) => ({
    email,
    ageGroup: age < 30 ? "young" : age < 60 ? "middle" : "senior",
    isEligibleForDiscount: age >= 65,
  })),
  (result) =>
    match(result, {
      ok: (data) => ({ success: true as const, data }),
      err: (errors) => ({ success: false as const, errors }),
    }),
);

await processContact({ email: "alice@example.com", age: "25" });
// → { success: true, data: { email: "alice@example.com", ageGroup: "young", isEligibleForDiscount: false } }

await processContact({ email: "not-an-email", age: "-5" });
// → { success: false, errors: [...] }
Enter fullscreen mode Exit fullscreen mode

Three steps. If validation fails, mapWith never runs. The match branches once, at the boundary. Adding a step means inserting one line.

Note parseNumber() above — it converts the string "25" to the number 25. Useful when data arrives from forms or CSVs where everything is a string. chain() composes validators left to right, and all errors accumulate — you see every problem at once.


The Full Operator Set

The minimal pipeline above covered the core shape: validate, transform, branch. Now let's add what a real pipeline needs — business rules with filterWith, async steps with flatMapWith, side effects with tapWith/tapErrWith, and error recovery with orElseWith.

Here's a complete order processing pipeline using every operator:

import { flowAsync } from "@railway-ts/pipelines/composition";
import {
  err,
  ok,
  filterWith,
  flatMapWith,
  mapErrWith,
  mapWith,
  match,
  orElseWith,
  tapErrWith,
  tapWith,
} from "@railway-ts/pipelines/result";
import {
  validate,
  object,
  required,
  chain,
  string,
  nonEmpty,
  email,
  parseNumber,
  min,
  max,
  formatErrors,
  type InferSchemaType,
  type ValidationError,
} from "@railway-ts/pipelines/schema";

const orderSchema = object({
  customerEmail: required(chain(string(), nonEmpty(), email())),
  item: required(chain(string(), nonEmpty())),
  quantity: required(chain(parseNumber(), min(1), max(100))),
  unitPrice: required(chain(parseNumber(), min(0.01))),
});

type Order = InferSchemaType<typeof orderSchema>;

const TAX_RATE = 0.08;
const MINIMUM_ORDER_TOTAL = 10;

const processOrder = flowAsync(
  (input: unknown) => validate(input, orderSchema),

  // Convert ValidationError[] to a human-readable string
  mapErrWith((errors: ValidationError[]) =>
    Object.entries(formatErrors(errors))
      .map(([field, msg]) => `${field}: ${msg}`)
      .join("; "),
  ),

  // Side effect on success — doesn't alter the Result
  tapWith((order: Order) =>
    console.log(`Validated: ${order.quantity}x ${order.item}`),
  ),

  // Business rule: if predicate fails, Ok becomes Err with given message
  filterWith(
    (order: Order) => order.quantity * order.unitPrice >= MINIMUM_ORDER_TOTAL,
    `Order total must be at least $${MINIMUM_ORDER_TOTAL}`,
  ),

  // Async step that can independently fail
  flatMapWith((order: Order) => checkInventory(order)),

  // Transform Ok without the ability to fail
  mapWith((order: Order) => {
    const total = order.quantity * order.unitPrice;
    return {
      ...order,
      total,
      totalWithTax: +(total * (1 + TAX_RATE)).toFixed(2),
    };
  }),

  // Side effect on error — doesn't alter the Result
  tapErrWith((error: string) => console.error("[order error]", error)),

  // Error recovery: return a new Ok, or re-raise
  orElseWith((error: string) => {
    if (error === "Item is out of stock") {
      return ok({
        backordered: true,
        message: "Item on back-order. You will be notified.",
      });
    }
    return err(error);
  }),

  // Branch once at the boundary
  (result) =>
    match(result, {
      ok: (data) => ({ success: true as const, data }),
      err: (error) => ({ success: false as const, error }),
    }),
);
Enter fullscreen mode Exit fullscreen mode
await processOrder({
  customerEmail: "alice@example.com",
  item: "widget",
  quantity: "3",
  unitPrice: "12.50",
});
// → { success: true, data: { ..., total: 37.5, totalWithTax: 40.5 } }

await processOrder({
  customerEmail: "not-an-email",
  item: "",
  quantity: "-5",
  unitPrice: "10",
});
// → { success: false, error: "customerEmail: Invalid email format; item: String must not be empty; quantity: Must be at least 1" }

await processOrder({
  customerEmail: "carol@example.com",
  item: "out-of-stock-widget",
  quantity: "5",
  unitPrice: "20",
});
// → { success: true, data: { backordered: true, message: "Item on back-order..." } }
Enter fullscreen mode Exit fullscreen mode

The pipeline reads top-to-bottom. The happy path and error path never intersect until match. Each step is a plain function you can test in isolation.


Typed Errors: When E Is a Discriminated Union

The examples above use string errors for readability. The real power of Result<T, E> shows when E is a discriminated union:

type OrderError =
  | { type: "validation"; fields: Record<string, string> }
  | { type: "inventory"; item: string }
  | { type: "payment"; code: string; retryable: boolean }
  | { type: "shipment"; reason: string };

const checkInventory = (order: Order): Result<Order, OrderError> =>
  order.item === "out-of-stock-widget"
    ? err({ type: "inventory", item: order.item })
    : ok(order);

const chargePayment = (order: Order): Result<Order, OrderError> =>
  order.quantity * order.unitPrice > 10_000
    ? err({ type: "payment", code: "LIMIT_EXCEEDED", retryable: false })
    : ok(order);
Enter fullscreen mode Exit fullscreen mode

Your error handling at the boundary pattern-matches on .type:

match(result, {
  ok: (data) => respond(200, data),
  err: (error) => {
    switch (error.type) {
      case "validation":
        return respond(400, error.fields);
      case "inventory":
        return respond(409, `${error.item} unavailable`);
      case "payment":
        return respond(402, { code: error.code, retryable: error.retryable });
      case "shipment":
        return respond(500, error.reason);
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

TypeScript narrows the error type at each branch. No instanceof, no hand-written type guards — just data.


Honest Comparison With Alternatives

Use neverthrow if you only need Result wrapping and you're already using Zod for validation. neverthrow gives you .andThen().map() method chaining on a Result type — a different shape from the curried flatMapWith/mapWith functions here, but equally expressive for linear chains. The real gap: neverthrow doesn't include validation, so you still pair it with Zod. That means Zod returns a plain object or throws, and neverthrow returns Result<T, E> — two error models your glue code bridges. Manageable for most apps, and neverthrow has a mature community.

Use fp-ts if your team thinks in Haskell abstractions and wants the full toolkit. Concretely: TaskEither for typed-error async, Reader for dependency injection, IO for effect tracking — tools railway-ts doesn't offer. The cost: fp-ts adds ~15–50 kB depending on imports, and the API uses category-theory naming (Alt, Bifunctor, Monad) that steepens onboarding. If your team is comfortable with that vocabulary, nothing in the TypeScript ecosystem is more comprehensive.

Use Effect if you're ready to adopt Effect as your application framework. Effect isn't a utility library — it ships its own runtime, a dependency-injection system (Layer/Context), structured concurrency, and a typed error channel. Exceptional when you're fully in it; significant overhead if you just want Result<T, E> and composable pipelines without re-architecting your app.

Use @railway-ts/pipelines if you want one library where validation and error handling share a type, and async pipelines compose naturally. It targets a narrower niche than fp-ts or Effect — Result + pipelines + validation — without requiring a new runtime or category-theory vocabulary. Particularly strong paired with @railway-ts/use-form — the same Result the pipeline produces is what the form hook consumes natively. That's Part 3.

Trade-offs: smaller ecosystem means less community coverage at 2am. Newer means less production history. The unified model couples validation and error handling — if you want to swap your validation library independently, separate packages give you that flexibility.


Links:

Next: Part 3 — Schema-First React Forms — the 3-layer error priority system that makes server errors, async field checks, and schema errors deterministic; array fields; and the full-stack payoff where one schema drives backend pipeline and frontend form state without any format conversion.

Top comments (0)