railway-effects
railway-effects
is a collection of modules that provides a consistent methodology for managing side effects and the errors produced by them consistently and comprehensively. It standardizes the Error creation process to create errors tagged with a code
property, provides tooling to wrap side effects in a Result
instance that represents success & failure, and wraps other ecosystem tools to fit comfortably within this model.
First, install the @railway-effects/result
& /error
packages:
npm i @railway-effects/result @railway-effects/error
These two packages make up the core of the railway-effects
approach.
For a given effect, you'll need to define a few parts for it. Let's use an API call with fetch
as an example.
import { UnknownError } from "@railway-effects/error";
import * as R from "@railway-effects/result";
const getUsers = async (): R.AsyncResult<unknown, UnknownError> => {
return await R.tryAsync(
async () => {
const response = await fetch("/api/users");
const body = await response.json();
return body;
},
(error) =>
new UnknownError("Unknown error fetching users", { cause: error }),
);
};
AsyncResult
as a convenience type for Promise<Result>
. The resulting function can be used as follows:
const result = await getUsers();
R.match(result, {
success: (data) => alert(`Got users: ${data}`),
error: (error) => alert(`Error fetching users: ${error.message}`),
});
While this looks like try/catch
with extra steps, we already have an improvement in the error case. While TypeScript by default will treat the thrown error as unknown
, we wrapped it up in an UnknownError
, which provides some extra details (including a code
property we can match on as well as stack
& message
).
However, this doesn't provide us much information about either what errors happened or what we got back from the API. Let's solve the errors first:
import { BaseError } from "@railway-effects/error";
import * as R from "@railway-effects/result";
class FetchError extends BaseError {
readonly code = "FETCH";
}
class JSONParseError extends BaseError {
readonly code = "JSON_PARSE";
}
const getUsers = async (): R.AsyncResult<
unknown,
FetchError | JSONParseError
> => {
const result = await R.tryAsync(
async () => fetch("/api/users"),
(error) =>
new FetchError("A fetch error occurred getting users", {
cause: error,
}),
);
return await R.andThen(result, (response) =>
R.tryAsync(
() => response.json(),
(error) =>
new JSONParseError("Error parsing JSON body", { cause: error }),
),
);
};
First we define two new Error classes that map to the potential errors that can occur in this sequence of effects. We split the two async calls into separate steps in the pipeline, removing the need for UnknownError
. R.andThen
will only run the provided callback if the result
is in the success state, which makes it easy to chain a sequence of effects with a chunk of data, accumulating the type of errors in the Result.
All of this enables us to more specifically understand the error generated by the pipeline:
const result = await getUsers();
R.match(result, {
success: (data) => alert(`Got users: ${data}`),
error: (error) => {
switch (error.code) {
case "FETCH":
return alert("Error occurred fetching users");
case "JSON_PARSE":
return alert("Error occurred parsing returned JSON body");
}
},
});
fetch
throws a lot of different errors, and for your given effect, you can expand the errors returned in the result to handle all possible scenarios, fully typed.
Unfortunately, the Result's success state is still unknown
. Let's fix that with zod
:
import { BaseError } from "@railway-effects/error";
import * as R from "@railway-effects/result";
import { parseWithResult, type ParseError } from "@railway-effects/zod";
import { z } from "zod";
class FetchError extends BaseError {
readonly code = "FETCH";
}
class JSONParseError extends BaseError {
readonly code = "JSON_PARSE";
}
const UserBodySchema = z.array(
z.object({
id: z.string(),
username: z.string(),
}),
);
const getUsers = (): R.AsyncResult<
z.infer<typeof UserBodySchema>,
FetchError | JSONParseError | ParseError
> => {
const result = await R.tryAsync(
async () => fetch("/api/users"),
(error) =>
new FetchError("A fetch error occurred getting users", {
cause: error,
}),
);
const result1 = await R.andThen(result, (response) =>
R.tryAsync(
() => response.json(),
(error) =>
new JSONParseError("Error parsing JSON body", { cause: error }),
),
);
return await R.andThen(result1, (body) =>
parseWithResult(UserBodySchema, body),
);
};
Couple of changes here: First, we create a Zod schema for the body returned by the API, UserBodySchema
. Next, we add an additional step to parse the body
with the schema and wrap it in a Result. The Result is returned in the success state with the parsed data if it succeeds, or in the error state with a ZodParseError
if it fails. Lastly, this updates the type with an explicit type in the success scenario and adds an additional ZodParseError
to the error scenario.
All of this flows forward to give us more full-fledged typing and error handling:
const result = await getUsers();
R.match(result, {
success: (data) =>
alert(
`Got users: ${data
.map((user) => user.username)
.join(", ")
.trim()}`,
),
error: (error) => {
switch (error.code) {
case "FETCH":
return alert("Error occurred fetching users");
case "JSON_PARSE":
return alert("Error occurred parsing returned JSON body");
case "ZOD_PARSE":
return alert("Returned body did not match schema");
}
},
});
We have specific types on the data
returned by the API and we have an opportunity to handle the Zod parsing error.
You may have noticed a couple of warts in getUsers
. For every step, we're stuck creating a new result
object and giving it another variable name. result1
is not a good variable name, and anything else is verbose and unnecessary when it's basically a temp var to pass into the next step in the sequence. To alleviate this, use andThenSeq
to chain a sequence of effects:
const getUsers = (): R.AsyncResult<
z.infer<typeof UserBodySchema>,
FetchError | JSONParseError | ParseError
> => {
return await R.andThenSeq(
R.tryAsync(
async () => fetch("/api/users"),
(error) =>
new FetchError("A fetch error occurred getting users", {
cause: error,
}),
),
(response) =>
R.tryAsync(
() => response.json(),
(error) =>
new JSONParseError("Error parsing JSON body", { cause: error }),
),
(body) => parseWithResult(UserBodySchema, body),
);
};
Now, each callback is called only if the previous step returns a Result in the success state. These pipelines of effects then become very easy to build and sequence while handling errors consistently.
Inspired by Rust's Result
and other functional-style error handling, railway-effects
attempts to implement a version of this in JavaScript/TypeScript in a way that feels more native to the language and easy to understand for developers otherwise unfamiliar with the paradigm. To those ends, the library has a few underlying principles.
This is where the library gets its name: Railway-Oriented Programming
While functional programming provides a strong foundation for solving a variety of programming tasks, without the requisite background knowledge in the underlying mathematical concepts that underpin functional programming, it can be very challenging for new developers to understand the control flow relative to the imperative style (with try/catch
) that they're used to.
railway-effects
already represents a departure from a more traditional imperative approach, so the goal of the library is to make this useful & understandable with a minimal reliance on deep functional concepts. This specifically means no currying, instead favoring a data-first API design that is comfortable to your typical lodash
user.
Additionally, the library relies on the Promise as its async primitive, eschewing lazy effects in favor of JavaScript-native async flows.
The library is built to be significantly easier to use when paired with a future pipeline operator. With the data-first design, any of the API functions can be used in a pipeline sequence, taking advantage of a future native JavaScript feature to design an API that is both classic & forward-looking.
const getUsers = (): R.AsyncResult<
z.infer<typeof UserBodySchema>,
FetchError | JSONParseError | ParseError
> => {
return R.tryAsync(
async () => fetch("/api/users"),
(error) =>
new FetchError("A fetch error occurred getting users", {
cause: error,
}),
) |> R.andThen(%, (response) =>
R.tryAsync(
() => response.json(),
(error) =>
new JSONParseError("Error parsing JSON body", { cause: error }),
),
) |> R.andThen(%, (body) => parseWithResult(UserBodySchema, body));
};
While it still uses andThen
instead of andThenSeq
, it enables the full API to be used in the pipeline.
Additionally, the switch
case for handling errors could use pattern matching to switch on & extract relevant information from each error type. If you're interested in using pattern matching-like syntax today, we recommend ts-pattern.
[!NOTE] The Pattern Matching syntax is still in flux, so an example is not provided.