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).
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:
What we want instead is a model where a failure is still a valid value that can move through the pipeline.
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.
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,
});Now we need two small helpers that make this work inside a pipeline.
mapmap 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;
};flatMapflatMap (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.
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;
};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);
};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);
};const hashPassword = (
input: RegisterInput
): Result<RegisteredUser> => {
const passwordHash = `hashed:${input.password}`;
return success({
email: input.email,
passwordHash,
});
};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.
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.
Railway Oriented Programming is not about adding ceremony. It is about making our code more honest.
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.