Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/maadhattah/railway-effects
https://github.com/maadhattah/railway-effects
Last synced: about 2 months ago
JSON representation
- Host: GitHub
- URL: https://github.com/maadhattah/railway-effects
- Owner: mAAdhaTTah
- Created: 2024-07-02T01:45:41.000Z (6 months ago)
- Default Branch: main
- Last Pushed: 2024-07-15T03:20:40.000Z (6 months ago)
- Last Synced: 2024-10-14T18:31:33.051Z (3 months ago)
- Language: TypeScript
- Homepage: https://maadhattah.github.io/railway-effects/
- Size: 686 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# `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.
## How to Use
First, install the `@railway-effects/result`& `/error` packages:
```sh
npm i @railway-effects/result @railway-effects/error
```These two packages make up the core of the `railway-effects` approach.
### Creating a Basic API Effect
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.
```ts
import { UnknownError } from "@railway-effects/error";
import * as R from "@railway-effects/result";const getUsers = async (): R.AsyncResult => {
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`. The resulting function can be used as follows:
```ts
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`).
### Adding Error Handling
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:
```ts
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:
```ts
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](https://developer.mozilla.org/en-US/docs/Web/API/fetch#exceptions), and for your given effect, you can expand the errors returned in the result to handle all possible scenarios, fully typed.
### Validating External Reads
Unfortunately, the Result's success state is still `unknown`. Let's fix that with `zod`:
```ts
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,
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](https://zod.dev/) 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:
```ts
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.
### Simplifying the sequence
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:
```ts
const getUsers = (): R.AsyncResult<
z.infer,
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.
## Philosophy
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.
### Railway-Oriented Programming
This is where the library gets its name: [Railway-Oriented Programming](https://fsharpforfunandprofit.com/rop/)
### Avoid Complex Functional Paradigms
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.
### Build for the Future
The library is built to be significantly easier to use when paired with a future [pipeline operator](https://github.com/tc39/proposal-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.
```ts
const getUsers = (): R.AsyncResult<
z.infer,
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](https://github.com/tc39/proposal-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](https://github.com/gvergnaud/ts-pattern).
> [!NOTE]
> The Pattern Matching syntax is still in flux, so an example is not provided.