Railway Oriented Programming in TypeScript

16 juni 2026

In the previous posts of this series, we’ve looked at pattern matching, pure functions, custom composition, and currying. This post is the natural next step: how do we handle errors inside a pipeline without turning the whole flow into a pile of try/catch blocks?

The short answer is: treat errors as data. That’s the heart of Railway Oriented Programming (ROP).

The problem with throwing inside a pipeline

A clean pipeline looks beautiful when everything succeeds. For example, this is the kind of flow we want:

const result = pipe(
  input,
  normalize,
  validate,
  enrich,
  save
);

But the moment one step fails, the whole story changes. If a step throws a native JavaScript Error, the pipeline stops running immediately.

const validateInput = (input: UserInput): UserInput => {
  if (!input.email.includes("@")) {
    throw new Error("Invalid email");
  }

  return input;
};

const result = pipe(
  input,
  validateInput,
  hashPassword,
  saveUser
);

That creates a few practical problems:

  • The pipeline is no longer composable in a clean way.
  • The type signature does not tell us what can go wrong.
  • Every caller must know how to recover from exceptions.
  • Runtime crashes become part of the flow.

What we want instead is a model where a failure is still a valid value that can move through the pipeline.

The happy track and the bypass track

A railway is a good mental model here. The value can stay on the happy track and keep moving forward, or it can switch to a bypass track when something goes wrong.

On the happy track, we keep transforming the value. On the bypass track, we stop doing more work and carry the error forward.

Building the result types

We can model this with a small discriminated union.

type Success<T> = {
  tag: "success";
  value: T;
};

type Failure = {
  tag: "failure";
  error: Error;
};

type Result<T> = Success<T> | Failure;

The tag field tells us exactly which branch we are in. That gives us a clear, explicit contract.

const success = <T>(value: T): Success<T> => ({
  tag: "success",
  value,
});

const failure = (error: Error): Failure => ({
  tag: "failure",
  error,
});

The railway adapters

Now we need two small helpers that make this work inside a pipeline.

map

map is for the “keep going, but change the value” case. If the current step succeeded and you only want to transform the payload, map is the right tool.

const map =
  <T, U>(fn: (value: T) => U) =>
  (result: Result<T>): Result<U> => {
    if (result.tag === "success") {
      return success(fn(result.value));
    }

    return result;
  };

flatMap

flatMap (sometimes called bind) is for the “run the next step, and that next step may itself fail” case. It lets us keep the pipeline flowing without losing the error state.

const flatMap =
  <T, U>(fn: (value: T) => Result<U>) =>
  (result: Result<T>): Result<U> => {
    if (result.tag === "success") {
      return fn(result.value);
    }

    return result;
  };

In practice, we often use them together. We use flatMap when the next step can fail and return another Result, and we use map when the next step only needs to shape the successful value.

const buildResponse = (user: RegisteredUser) => ({
  id: "user-123",
  email: user.email,
});

const registerUser = (input: RegisterInput): Result<{ id: string; email: string }> =>
  pipe(
    input,
    validateInput,
    flatMap(checkEmailExists),
    flatMap(hashPassword),
    map(buildResponse)
  );

If the current result is a failure, the pipeline keeps that failure and avoids doing further work.

A realistic registration flow

Let’s look at a pragmatic example: validating and creating a user. Each step returns a Result.

type RegisterInput = {
  email: string;
  password: string;
};

type RegisteredUser = {
  email: string;
  passwordHash: string;
};

Validate the input

const validateInput = (
  input: RegisterInput
): Result<RegisterInput> => {
  const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;

  if (!emailRegex.test(input.email)) {
    return failure(new Error("Invalid email address"));
  }

  if (input.password.length < 8) {
    return failure(new Error("Password must be at least 8 characters"));
  }

  return success(input);
};

Check whether the email already exists

const checkEmailExists = (
  input: RegisterInput
): Result<RegisterInput> => {
  const takenEmails = new Set(["already@taken.com"]);

  if (takenEmails.has(input.email)) {
    return failure(new Error("Email address is already in use"));
  }

  return success(input);
};

Hash the password

const hashPassword = (
  input: RegisterInput
): Result<RegisteredUser> => {
  const passwordHash = `hashed:${input.password}`;

  return success({
    email: input.email,
    passwordHash,
  });
};

Compose the pipeline

Assuming we still have our custom pipe helper from the previous post, the flow becomes very readable:

const registerUser = (
  input: RegisterInput
): Result<RegisteredUser> =>
  pipe(
    input,
    validateInput,
    flatMap(checkEmailExists),
    flatMap(hashPassword)
  );

This is exactly what we want from a pipeline: each step can decide whether to continue or short-circuit.

The final station: handling the result

At the controller boundary, we can use ts-pattern to handle both branches explicitly.

import { match } from "ts-pattern";

const toHttpResponse = (result: Result<RegisteredUser>) =>
  match(result)
    .with({ tag: "success" }, ({ value }) => ({
      status: 201,
      body: {
        message: "User created",
        user: value,
      },
    }))
    .with({ tag: "failure" }, ({ error }) => ({
      status: 400,
      body: {
        message: error.message,
      },
    }))
    .exhaustive();

Notice how the controller now has a clear contract. It does not need to guess what happened inside the pipeline.

Why this approach is so useful

Railway Oriented Programming is not about adding ceremony. It is about making our code more honest.

  • Errors are explicit instead of hidden inside exceptions.
  • Pipelines stay readable and composable.
  • TypeScript gives us a clearer model of what can happen.
  • Business rules become easier to reason about.

In short: we stop pretending everything always succeeds, and instead model success and failure in a way that the runtime and the type system can both understand.