diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3fda2..d7d2ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. ### Added - `TaskEither` - a lazy async computation that wraps `() => Promise>`, with `map`, `mapLeft`, `flatMap`, `flatten`, `ap`, `zip`, `tap`, `tapLeft`, `match`, `getOrElse`, `orElse`, and `run` +- Full `TaskEither` documentation +- `TaskEither` section added to the Getting Started page ## [0.2.1](https://github.com/pwlmc/okfp/compare/v0.2.0...v0.2.1) - 2026-02-25 diff --git a/README.md b/README.md index d396a52..9786f64 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ OK-FP is a small, focused functional programming toolkit for TypeScript. It provides a minimal set of **typed effects**: composable, type-safe wrappers for optional values, errors, and async computations. -## Status - -OK-FP is pre-1.0. - -- ✅ Implemented: `Option`, `Either`, `Validation`, `Task` -- 🚧 Planned before `v1.0.0`: `TaskEither` - -See: [ROADMAP.md](./ROADMAP.md) +## Effects + +| Effect | Description | +|---|---| +| [`Option`](./docs/option.md) | A value that may or may not be present. Use instead of `null`/`undefined`. | +| [`Either`](./docs/either.md) | A computation that succeeds with `T` or fails with a typed error `E`. Errors are explicit and must be handled. | +| [`Validation`](./docs/validation.md) | Like `Either`, but accumulates **all** errors instead of stopping at the first one. Ideal for form and config validation. | +| [`Task`](./docs/task.md) | A lazy async computation that always succeeds. Executes only when `.run()` is called — unlike Promises, which are eager. | +| [`TaskEither`](./docs/task-either.md) | A lazy async computation that can succeed with `T` or fail with `E`. Combines `Task`'s laziness with `Either`'s typed errors. | ## Installation diff --git a/ROADMAP.md b/ROADMAP.md index 8eb248a..34965e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,5 +8,6 @@ - [x] (feat) Validation (applicative) - [x] (feat) Task - [x] (feat) TaskEither +- [x] (doc) TaskEither documentation - [ ] (doc) Document design decisions - [ ] (doc) Comprehensive FP effects guide diff --git a/docs/index.md b/docs/index.md index 264cf9e..788d365 100644 --- a/docs/index.md +++ b/docs/index.md @@ -180,3 +180,40 @@ const [user1, user2] = await all([ ::: **Dive deeper into `Task`:** See the [Task guide](./task.md) for all available methods and patterns. + +## Fallible Async Computations: TaskEither + +When your async operation can fail with a typed error, use `TaskEither`. It is a lazy `() => Promise>` — combining `Task`'s laziness with `Either`'s typed error handling. + +```ts +import { tryCatch, taskEither, all } from "ok-fp/taskEither"; + +type User = { id: string; name: string }; + +const fetchUser = (id: string) => + tryCatch( + () => fetch(`/api/users/${id}`).then((r) => r.json() as Promise), + (err) => `Fetch failed: ${err}`, + ); + +// Chain two async steps — short-circuits on first error +const greeting = fetchUser("a-001") + .map((user) => `Hello, ${user.name}!`); + +const result = await greeting.run(); +result.match( + (err) => console.error(err), + (msg) => console.log(msg), // "Hello, Alice!" +); + +// Run multiple requests concurrently +const [user1, user2] = await all([fetchUser("a-001"), fetchUser("b-002")]) + .getOrElse(() => []) + .run(); +``` + +::: tip Key takeaway +`TaskEither` makes the error type visible in the signature and forces you to handle it. Use it for any async operation that can fail — API requests, file reads, database queries. +::: + +**Dive deeper into `TaskEither`:** See the [TaskEither guide](./task-either.md) for all available methods and patterns. diff --git a/docs/task-either.md b/docs/task-either.md index 36c7788..c6cf998 100644 --- a/docs/task-either.md +++ b/docs/task-either.md @@ -1,9 +1,5 @@ # TaskEither -::: warning Work in Progress -`TaskEither` is planned for release before `v1.0.0`. The API described here reflects the current design and may change. -::: - `TaskEither` represents a **lazy asynchronous computation** that can succeed with a value of type `T` or fail with an error of type `E`. It combines the laziness of `Task` with the error handling of `Either` - think of it as a `() => Promise>`. @@ -24,4 +20,363 @@ async function unsafeFetch(url: string): Promise { /* ... */ } // TaskEither makes it explicit: function safeFetch(url: string): TaskEither { /* ... */ } +``` + +## Basic Usage + +### Creating a TaskEither + +```ts +import { taskEither, taskLeft, fromEither, fromTask, tryCatch } from "ok-fp/taskEither"; + +const success = taskEither(42); // TaskEither - resolves to Right(42) +const failure = taskLeft("not found"); // TaskEither - resolves to Left("not found") + +// Wrap an existing Either +const fromE = fromEither(right(10)); // TaskEither + +// Wrap a Task (always succeeds) +const fromT = fromTask(task(5)); // TaskEither + +// Safely wrap a Promise that may reject +const safe = tryCatch( + () => fetch("/api/user").then((r) => r.json()), + (err) => `Fetch failed: ${err}`, +); // TaskEither +``` + +### Transforming and Chaining + +```ts +import { taskEither, taskLeft, tryCatch } from "ok-fp/taskEither"; + +type User = { id: string; name: string }; + +const fetchUser = (id: string) => + tryCatch( + () => fetch(`/api/users/${id}`).then((r) => r.json() as Promise), + (err) => `Fetch failed: ${err}`, + ); + +const greeting = fetchUser("a-001") + .map((user) => user.name) // transform the success value + .map((name) => `Hello, ${name}!`); // chain another transformation + +const result = await greeting.run(); // Either +``` + +### Error Handling + +```ts +import { taskEither, taskLeft } from "ok-fp/taskEither"; + +const result = await taskLeft("not found") + .orElse(() => taskEither(0)) // recover from Left + .run(); // Right(0) + +const matched = await taskLeft("error") + .match( + (err) => `Failed: ${err}`, + (val) => `Got: ${val}`, + ) + .run(); // "Failed: error" +``` + +### Running a TaskEither + +```ts +import { taskEither, taskLeft } from "ok-fp/taskEither"; + +const result = await taskEither(42).run(); // Either → Right(42) +const error = await taskLeft("oops").run(); // Either → Left("oops") +``` + +--- + +## API Reference + +### taskEither + +```ts +taskEither(value: T): TaskEither +``` + +Create a TaskEither that resolves to `Right` with the provided value. + +```ts +taskEither(42); // TaskEither +taskEither("hello"); // TaskEither +``` + +--- + +### taskLeft + +```ts +taskLeft(error: E): TaskEither +``` + +Create a TaskEither that resolves to `Left` with the provided error. + +```ts +taskLeft("not found"); // TaskEither +taskLeft(404); // TaskEither +``` + +--- + +### fromEither + +```ts +fromEither(either: Either): TaskEither +``` + +Lift an existing `Either` into a `TaskEither`. + +```ts +fromEither(right(42)); // TaskEither +fromEither(left("error")); // TaskEither +``` + +--- + +### fromTask + +```ts +fromTask(t: Task): TaskEither +``` + +Lift a `Task` (which always succeeds) into a `TaskEither` that always resolves to `Right`. + +```ts +fromTask(task(5)); // TaskEither - always Right(5) +``` + +--- + +### tryCatch + +```ts +tryCatch(thunk: () => Promise, onThrow: (err: unknown) => E): TaskEither +``` + +Safely wrap a Promise-returning thunk that may reject. Rejections are caught and converted to `Left` using `onThrow`. + +```ts +tryCatch( + () => fetch("/api/data").then((r) => r.json()), + (err) => `Request failed: ${err}`, +); +// Right(data) on success, Left("Request failed: ...") on rejection +``` + +--- + +### map + +```ts +map(mapper: (value: T) => U): TaskEither +``` + +Transform the `Right` value. If this resolves to `Left`, the mapper is not called. + +```ts +taskEither(5).map((n) => n * 2); // resolves to Right(10) +taskLeft("error").map((n) => n * 2); // resolves to Left("error") +``` + +--- + +### mapLeft + +```ts +mapLeft(mapper: (error: E) => F): TaskEither +``` + +Transform the `Left` value. If this resolves to `Right`, the mapper is not called. + +```ts +taskLeft("error").mapLeft((e) => `mapped: ${e}`); // resolves to Left("mapped: error") +taskEither(42).mapLeft((e) => `mapped: ${e}`); // resolves to Right(42) +``` + +--- + +### flatMap + +```ts +flatMap(mapper: (value: T) => TaskEither): TaskEither +``` + +Chain TaskEither-returning operations together. Short-circuits to `Left` if this resolves to `Left`. + +```ts +taskEither(10).flatMap((x) => taskEither(x * 2)); // resolves to Right(20) +taskLeft("error").flatMap((x) => taskEither(x)); // resolves to Left("error") + +// Real-world: chain two async steps +const fetchProfile = (id: string) => + tryCatch(() => fetch(`/api/profile/${id}`).then((r) => r.json()), String); + +taskEither("user-1").flatMap(fetchProfile); +``` + +--- + +### flatten + +```ts +flatten(): TaskEither // where this is TaskEither> +``` + +Remove one level of nesting from a nested `TaskEither`. + +```ts +taskEither(taskEither(42)).flatten(); // resolves to Right(42) +taskEither(taskLeft("inner error")).flatten(); // resolves to Left("inner error") +``` + +--- + +### zip + +```ts +zip(other: TaskEither): TaskEither +``` + +Combine two `TaskEither` values into a tuple. Both are run concurrently. Returns the first `Left` if either fails. + +```ts +taskEither("Alice").zip(taskEither(30)); // resolves to Right(["Alice", 30]) +taskEither("Alice").zip(taskLeft("No age")); // resolves to Left("No age") +``` + +--- + +### ap + +```ts +ap(this: TaskEither U>, arg: TaskEither): TaskEither +``` + +Apply a function wrapped in a `TaskEither` to a value wrapped in a `TaskEither`. Both are run concurrently. + +```ts +const add = (x: number) => (y: number) => x + y; +taskEither(add(5)).ap(taskEither(3)); // resolves to Right(8) +taskEither(add(5)).ap(taskLeft("err")); // resolves to Left("err") +``` + +--- + +### tap + +```ts +tap(sideEffect: (value: T) => unknown): TaskEither +``` + +Run a side effect with the `Right` value. Returns the original `TaskEither` unchanged. + +```ts +await taskEither(42).tap((v) => console.log("value:", v)).run(); +// logs "value: 42", resolves to Right(42) + +await taskLeft("error").tap((v) => console.log("value:", v)).run(); +// no log, resolves to Left("error") +``` + +--- + +### tapLeft + +```ts +tapLeft(sideEffect: (error: E) => unknown): TaskEither +``` + +Run a side effect with the `Left` value. Returns the original `TaskEither` unchanged. + +```ts +await taskLeft("error").tapLeft((e) => console.error("error:", e)).run(); +// logs "error: error", resolves to Left("error") + +await taskEither(42).tapLeft((e) => console.error("error:", e)).run(); +// no log, resolves to Right(42) +``` + +--- + +### match + +```ts +match(onLeft: (error: E) => U, onRight: (value: T) => U): Task +``` + +Pattern match on the resolved `Either`. Returns the result wrapped in a `Task`. + +```ts +await taskEither(5).match(() => 0, (x) => x * 2).run(); // 10 +await taskLeft("error").match(() => 0, (x) => x * 2).run(); // 0 +``` + +--- + +### getOrElse + +```ts +getOrElse(fallback: (error: E) => T): Task +``` + +Extract the `Right` value, or return a fallback computed from the `Left`. Returns the result wrapped in a `Task`. + +```ts +await taskEither(42).getOrElse(() => 0).run(); // 42 +await taskLeft("error").getOrElse(() => 0).run(); // 0 +``` + +--- + +### orElse + +```ts +orElse(fallback: (error: E) => TaskEither): TaskEither +``` + +Return this `TaskEither` if it resolves to `Right`, otherwise recover with the fallback. + +```ts +taskEither(42).orElse(() => taskEither(0)).run(); // Right(42) +taskLeft("error").orElse(() => taskEither(0)).run(); // Right(0) +taskLeft("error").orElse(() => taskLeft("still bad")).run(); // Left("still bad") +``` + +--- + +### run + +```ts +run(): Promise> +``` + +Execute the `TaskEither` and return the resulting Promise. Nothing runs until this is called. + +```ts +const result = await taskEither(42).run(); // Right(42) +const error = await taskLeft("oops").run(); // Left("oops") +``` + +--- + +### all + +```ts +all(taskEithers: TaskEither[]): TaskEither +``` + +Run all `TaskEither` values concurrently and collect their `Right` values into an array. Returns the first `Left` if any fails. + +```ts +import { taskEither, taskLeft, all } from "ok-fp/taskEither"; + +await all([taskEither(1), taskEither(2), taskEither(3)]).run(); // Right([1, 2, 3]) +await all([taskEither(1), taskLeft("err"), taskEither(3)]).run(); // Left("err") ``` \ No newline at end of file