react-router-effect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # react-router-effect
2
+
3
+ Wrap [React Router](https://reactrouter.com) framework-mode loaders and actions with
4
+ [Effect](https://effect.website), and get **typed, declarative error handling** for free.
5
+
6
+ Write your loader/action as an `Effect`. When it short-circuits with a tagged error, the
7
+ library decides — based on handlers you register once — whether to **recover** (return data
8
+ the component reads via `useLoaderData`) or **throw** (send it to the error boundary or issue
9
+ a redirect). The resolved type of every loader/action reflects exactly what it can return.
10
+
11
+ ```ts
12
+ const loader = makeLoader((args: Route.LoaderArgs) =>
13
+ Effect.gen(function* () {
14
+ const user = yield* getUser(args); // may fail with your domain errors
15
+ if (!user.onboarded) {
16
+ yield* Respond.redirect("/onboarding"); // throw → redirect
17
+ }
18
+ return { user };
19
+ }),
20
+ );
21
+ // loader: (args) => Promise<{ user: User } /* | recovered shapes */>
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ vp add react-router-effect effect react-router
28
+ ```
29
+
30
+ `effect` and `react-router` are peer dependencies.
31
+
32
+ ## Concepts
33
+
34
+ A loader/action effect can short-circuit in three ways, via the `Respond` helpers:
35
+
36
+ | Helper | Outcome | Where it lands |
37
+ | ------------------------------ | ----------- | --------------------------------------------------- |
38
+ | `Respond.early(value, init?)` | **recover** | resolves with `data(value, init)` → `useLoaderData` |
39
+ | `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
40
+ | `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
41
+
42
+ Your own **domain errors** are mapped to one of those outcomes by handlers you register with
43
+ the factory. A handler _remaps_ an error by returning either:
44
+
45
+ - a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
46
+ - an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
47
+ to **throw** a `Response` / `DataWithResponseInit`.
48
+
49
+ Handling is **optional**:
50
+
51
+ - An **unregistered** error that implements [`HttpServerRespondable`](https://effect.website)
52
+ is rendered automatically from its own response.
53
+ - Any other unregistered error falls through to a **500**.
54
+
55
+ ## Usage
56
+
57
+ ### 1. Configure the factory once
58
+
59
+ ```ts
60
+ // app/route.server.ts
61
+ import { Data, Effect } from "effect";
62
+ import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-effect";
63
+
64
+ class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
65
+ class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
66
+
67
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory({
68
+ errorHandlers: {
69
+ // recover: short-circuit and hand the reply to the component
70
+ FormError: (error: FormError) => baseRespond.early({ reply: error.reply }),
71
+ // throw: send to the error boundary
72
+ BadInputError: (error: BadInputError) =>
73
+ Effect.fail(new Response(error.message, { status: 400 })),
74
+ },
75
+ });
76
+
77
+ // Extend Respond with app-specific helpers if you like:
78
+ export const Respond = {
79
+ ...baseRespond,
80
+ formError: (reply: SubmissionResponse) => new FormError({ reply }),
81
+ };
82
+ ```
83
+
84
+ > **Annotate each handler's parameter.** The registered error set and the precise recover
85
+ > types are derived from the handler map's parameter and return types.
86
+
87
+ ### 2. Write loaders/actions as effects
88
+
89
+ ```ts
90
+ // app/routes/profile.ts
91
+ import { Effect } from "effect";
92
+ import { makeLoader, Respond } from "../route.server.ts";
93
+ import type { Route } from "./+types/profile";
94
+
95
+ const loaderEffect = ({ params }: Route.LoaderArgs) =>
96
+ Effect.gen(function* () {
97
+ const profile = yield* getProfile(params.id); // may fail with FormError / BadInputError
98
+ if (!profile.public) {
99
+ yield* Respond.early({ restricted: true }); // recover with typed data
100
+ }
101
+ return { profile };
102
+ });
103
+
104
+ export const loader = makeLoader((args: Route.LoaderArgs) => loaderEffect(args));
105
+ ```
106
+
107
+ The resolved type is computed from the effect and your handlers:
108
+
109
+ ```ts
110
+ type LoaderData = Route.ComponentProps["loaderData"];
111
+ // { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
112
+ // ( BadInputError throws, so it never appears here )
113
+ ```
114
+
115
+ ### Self-rendering domain errors
116
+
117
+ If a domain error implements `HttpServerRespondable`, you don't need to register a handler —
118
+ it renders its own response:
119
+
120
+ ```ts
121
+ import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
122
+
123
+ class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
124
+ [HttpServerRespondable.symbol]() {
125
+ return HttpServerResponse.json({ error: "Not authorized" }, { status: 403 }).pipe(Effect.orDie);
126
+ }
127
+ }
128
+ ```
129
+
130
+ A registered handler, if present, still takes precedence over the error's own response.
131
+
132
+ ## API
133
+
134
+ - **`makeLoaderOrActionFactory({ errorHandlers })`** → `{ makeLoader, makeAction, makeLoaderOrAction }`
135
+ (the three are the same wrapper under different names).
136
+ - **`Respond`** — `early` (recover), `throw`, `redirect`.
137
+ - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
138
+ route errors, and **`isRouteError`** to narrow them.
139
+ - **`ErrorHandler<Err>`** — the handler signature type.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ vp install # install dependencies
145
+ vp test # run the test suite
146
+ vp check # format, lint, type-check
147
+ vp pack # build the library
148
+ ```
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,120 @@
1
+ import { Effect } from "effect";
2
+ import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
3
+
4
+ //#region src/errors.d.ts
5
+ declare const ReturnableDataError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
6
+ readonly _tag: "ReturnableRouteError";
7
+ } & Readonly<A>;
8
+ /** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
9
+ declare class ReturnableDataError<D> extends ReturnableDataError_base<{
10
+ data: D;
11
+ init?: number | ResponseInit;
12
+ }> {}
13
+ declare const ThrowableDataError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
14
+ readonly _tag: "ThrowableRouteError";
15
+ } & Readonly<A>;
16
+ /** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
17
+ declare class ThrowableDataError<D> extends ThrowableDataError_base<{
18
+ data: D;
19
+ init?: number | ResponseInit;
20
+ }> {}
21
+ declare const ThrowableRedirectError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
22
+ readonly _tag: "ThrowableRedirectError";
23
+ } & Readonly<A>;
24
+ /** Throwable: rejects with a redirect `Response`. */
25
+ declare class ThrowableRedirectError extends ThrowableRedirectError_base<{
26
+ url: string;
27
+ init?: number | ResponseInit;
28
+ }> {}
29
+ /** Every library route error, as a single union. */
30
+ type AnyRouteError = ReturnableDataError<unknown> | ThrowableDataError<unknown> | ThrowableRedirectError;
31
+ /**
32
+ * Standardized helpers for raising the library's route errors. Consumers extend it
33
+ * by spreading into their own object — opinionated helpers (e.g. a form-library
34
+ * `formError`) live in the consumer, not here:
35
+ *
36
+ * ```ts
37
+ * export const Respond = {
38
+ * ...baseRespond,
39
+ * formError: (reply) => baseRespond.early({ reply }, 400),
40
+ * };
41
+ * ```
42
+ */
43
+ declare const Respond: {
44
+ /** Recover: short-circuit and hand `value` to the component as loader/action data. */early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>; /** Throw: short-circuit to the error boundary with `data(value, init)`. */
45
+ throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>; /** Throw: short-circuit with a redirect `Response`. */
46
+ redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
47
+ };
48
+ /** Narrows an unknown value to one of the library's route errors. */
49
+ declare const isRouteError: (value: unknown) => value is AnyRouteError;
50
+ //#endregion
51
+ //#region src/factory.d.ts
52
+ /**
53
+ * Everything a failure branch rejects with. React Router routes a thrown
54
+ * `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
55
+ * `Response` (including `redirect(...)`) as-is. Pinning every `Effect.fail` branch
56
+ * to this union keeps inference from fixing the error channel to the first one.
57
+ */
58
+ type FailureResponse = UNSAFE_DataWithResponseInit<unknown> | Response;
59
+ /**
60
+ * What a handler may return, to *remap* a domain error:
61
+ * - a **library route error** — `Respond.early(value)` (recover), `Respond.throw(data)`
62
+ * or `Respond.redirect(url)` (throw) — which the library then processes; or
63
+ * - an **`Effect`** — `Effect.succeed(value)` to recover with `value`, or
64
+ * `Effect.fail(response)` to throw `response`.
65
+ */
66
+ type ErrorHandler<Err> = (error: Err) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
67
+ /** The body of every `ReturnableDataError` raised directly in `E` (`Respond.early`). */
68
+ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : never;
69
+ /**
70
+ * The recover contribution of a `Respond.early` raised directly in the loader.
71
+ * Guarded so a loader that never recovers directly doesn't leak
72
+ * `DataWithResponseInit<never>` into its return type.
73
+ */
74
+ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_DataWithResponseInit<ReturnableBodyOf<E>>;
75
+ /**
76
+ * What the registered handlers recover into loader/action data — derived from the
77
+ * handlers' returns, but only for handlers whose error can actually occur in `E`:
78
+ * - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
79
+ * - an `Effect.succeed(value)` recovers as `value`;
80
+ * - throwables, redirects and `Effect.fail(...)` contribute nothing.
81
+ */
82
+ type RecoverOf<Handlers, E> = { [Tag in keyof Handlers]: Handlers[Tag] extends ((error: infer Err) => infer R) ? [Extract<E, Err>] extends [never] ? never : (R extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (R extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never) : never }[keyof Handlers];
83
+ /** Shape every handler must satisfy: return a library route error or an `Effect` failing with a response. */
84
+ type ValidHandlers = Record<string, (error: never) => AnyRouteError | Effect.Effect<unknown, FailureResponse>>;
85
+ /**
86
+ * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
87
+ *
88
+ * Register a handler per domain error in `errorHandlers` (**annotate each handler's
89
+ * param** — the registered set and recover types are derived from the map). A
90
+ * handler *remaps* the error by returning either a library route error
91
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
92
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
93
+ * throw). Recovered values become loader/action data, typed precisely (non-generic).
94
+ *
95
+ * Handling is optional: an *unregistered* error is allowed. If it implements
96
+ * `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
97
+ * error boundary as a 500.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
102
+ * errorHandlers: {
103
+ * // throw → error boundary
104
+ * MyDomainError: (error: MyDomainError) =>
105
+ * Effect.fail(new Response(error.message, { status: 400 })),
106
+ * // remap → recover via a library returnable
107
+ * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
108
+ * },
109
+ * });
110
+ * ```
111
+ */
112
+ declare function makeLoaderOrActionFactory<const Handlers>(config: {
113
+ errorHandlers: Handlers;
114
+ }, ..._validate: Handlers extends ValidHandlers ? [] : [eachHandlerMustReturnARouteErrorOrAnEffectFailingWithAResponse: ValidHandlers]): {
115
+ makeLoaderOrAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
116
+ makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
117
+ makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
118
+ };
119
+ //#endregion
120
+ export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
package/dist/index.mjs ADDED
@@ -0,0 +1,99 @@
1
+ import { Data, Effect } from "effect";
2
+ import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
3
+ import { data, redirect } from "react-router";
4
+ //#region src/errors.ts
5
+ /** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
6
+ var ReturnableDataError = class extends Data.TaggedError("ReturnableRouteError") {};
7
+ /** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
8
+ var ThrowableDataError = class extends Data.TaggedError("ThrowableRouteError") {};
9
+ /** Throwable: rejects with a redirect `Response`. */
10
+ var ThrowableRedirectError = class extends Data.TaggedError("ThrowableRedirectError") {};
11
+ /**
12
+ * Standardized helpers for raising the library's route errors. Consumers extend it
13
+ * by spreading into their own object — opinionated helpers (e.g. a form-library
14
+ * `formError`) live in the consumer, not here:
15
+ *
16
+ * ```ts
17
+ * export const Respond = {
18
+ * ...baseRespond,
19
+ * formError: (reply) => baseRespond.early({ reply }, 400),
20
+ * };
21
+ * ```
22
+ */
23
+ const Respond = {
24
+ /** Recover: short-circuit and hand `value` to the component as loader/action data. */
25
+ early: (value, init) => new ReturnableDataError({
26
+ data: value,
27
+ init
28
+ }),
29
+ /** Throw: short-circuit to the error boundary with `data(value, init)`. */
30
+ throw: (value, init) => new ThrowableDataError({
31
+ data: value,
32
+ init
33
+ }),
34
+ /** Throw: short-circuit with a redirect `Response`. */
35
+ redirect: (url, init) => new ThrowableRedirectError({
36
+ url,
37
+ init
38
+ })
39
+ };
40
+ /** Narrows an unknown value to one of the library's route errors. */
41
+ const isRouteError = (value) => value instanceof ReturnableDataError || value instanceof ThrowableDataError || value instanceof ThrowableRedirectError;
42
+ //#endregion
43
+ //#region src/factory.ts
44
+ const internalServerError = () => Effect.fail(new Response("Internal Server Error", { status: 500 }));
45
+ /** The internal dispatch for a library route error: recover, throw, or redirect. */
46
+ const processRouteError = (e) => {
47
+ if (e instanceof ReturnableDataError) return Effect.succeed(data(e.data, e.init));
48
+ if (e instanceof ThrowableDataError) return Effect.fail(data(e.data, e.init));
49
+ return Effect.fail(redirect(e.url, e.init));
50
+ };
51
+ /**
52
+ * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
53
+ *
54
+ * Register a handler per domain error in `errorHandlers` (**annotate each handler's
55
+ * param** — the registered set and recover types are derived from the map). A
56
+ * handler *remaps* the error by returning either a library route error
57
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
58
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
59
+ * throw). Recovered values become loader/action data, typed precisely (non-generic).
60
+ *
61
+ * Handling is optional: an *unregistered* error is allowed. If it implements
62
+ * `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
63
+ * error boundary as a 500.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
68
+ * errorHandlers: {
69
+ * // throw → error boundary
70
+ * MyDomainError: (error: MyDomainError) =>
71
+ * Effect.fail(new Response(error.message, { status: 400 })),
72
+ * // remap → recover via a library returnable
73
+ * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
74
+ * },
75
+ * });
76
+ * ```
77
+ */
78
+ function makeLoaderOrActionFactory(config, ..._validate) {
79
+ const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in config.errorHandlers;
80
+ const userHandlers = config.errorHandlers;
81
+ function makeLoaderOrAction(fn) {
82
+ return (args) => Effect.runPromise(fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
83
+ if (isUserError(e)) {
84
+ const out = userHandlers[e._tag](e);
85
+ return isRouteError(out) ? processRouteError(out) : out;
86
+ }
87
+ if (isRouteError(e)) return processRouteError(e);
88
+ if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
89
+ return internalServerError();
90
+ })));
91
+ }
92
+ return {
93
+ makeLoaderOrAction,
94
+ makeLoader: makeLoaderOrAction,
95
+ makeAction: makeLoaderOrAction
96
+ };
97
+ }
98
+ //#endregion
99
+ export { Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://schemastore.org/package.json",
3
+ "name": "react-router-effect",
4
+ "version": "0.1.0",
5
+ "description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
6
+ "keywords": [
7
+ "action",
8
+ "effect",
9
+ "error-handling",
10
+ "loader",
11
+ "react-router",
12
+ "typescript"
13
+ ],
14
+ "homepage": "https://github.com/justinwaite/react-router-effect#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/justinwaite/react-router-effect/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Justin Waite <justindwaite@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/justinwaite/react-router-effect.git"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "!src/**/*.test.ts"
28
+ ],
29
+ "type": "module",
30
+ "exports": {
31
+ ".": "./dist/index.mjs",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "devDependencies": {
38
+ "@changesets/changelog-github": "^0.7.0",
39
+ "@changesets/cli": "^2.31.0",
40
+ "@types/node": "^25.6.2",
41
+ "@types/react": "^19.2.17",
42
+ "@types/react-dom": "^19.2.3",
43
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
44
+ "@vitejs/plugin-react": "^6.0.1",
45
+ "@vitest/browser": "4.1.9",
46
+ "@vitest/browser-playwright": "4.1.9",
47
+ "effect": "4.0.0-beta.88",
48
+ "playwright": "1.60.0",
49
+ "react": "^19.2.7",
50
+ "react-dom": "^19.2.7",
51
+ "react-router": "^7.16.0",
52
+ "typescript": "^6.0.3",
53
+ "vite-plus": "latest",
54
+ "vitest": "4.1.9",
55
+ "vitest-browser-react": "^2.2.0"
56
+ },
57
+ "peerDependencies": {
58
+ "effect": "^4.0.0-beta.88",
59
+ "react-router": "^7.16.0"
60
+ },
61
+ "devEngines": {
62
+ "packageManager": {
63
+ "name": "pnpm",
64
+ "version": "11.9.0",
65
+ "onFail": "download"
66
+ }
67
+ },
68
+ "scripts": {
69
+ "build": "vp pack",
70
+ "dev": "vp pack --watch",
71
+ "test": "vp test",
72
+ "test:unit": "vp test run --project unit",
73
+ "test:browser": "vp test run --project browser",
74
+ "test:browser:install": "vp exec playwright install chromium",
75
+ "check": "vp check",
76
+ "changeset": "changeset",
77
+ "changeset:version": "changeset version",
78
+ "changeset:release": "vp run build && changeset publish"
79
+ }
80
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { Data } from "effect";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Standardized errors — the library translates these out of the box.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
8
+ export class ReturnableDataError<D> extends Data.TaggedError("ReturnableRouteError")<{
9
+ data: D;
10
+ init?: number | ResponseInit;
11
+ }> {}
12
+
13
+ /** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
14
+ export class ThrowableDataError<D> extends Data.TaggedError("ThrowableRouteError")<{
15
+ data: D;
16
+ init?: number | ResponseInit;
17
+ }> {}
18
+
19
+ /** Throwable: rejects with a redirect `Response`. */
20
+ export class ThrowableRedirectError extends Data.TaggedError("ThrowableRedirectError")<{
21
+ url: string;
22
+ init?: number | ResponseInit;
23
+ }> {}
24
+
25
+ /** Every library route error, as a single union. */
26
+ export type AnyRouteError =
27
+ | ReturnableDataError<unknown>
28
+ | ThrowableDataError<unknown>
29
+ | ThrowableRedirectError;
30
+
31
+ /**
32
+ * Standardized helpers for raising the library's route errors. Consumers extend it
33
+ * by spreading into their own object — opinionated helpers (e.g. a form-library
34
+ * `formError`) live in the consumer, not here:
35
+ *
36
+ * ```ts
37
+ * export const Respond = {
38
+ * ...baseRespond,
39
+ * formError: (reply) => baseRespond.early({ reply }, 400),
40
+ * };
41
+ * ```
42
+ */
43
+ export const Respond = {
44
+ /** Recover: short-circuit and hand `value` to the component as loader/action data. */
45
+ early: <D>(value: D, init?: number | ResponseInit) =>
46
+ new ReturnableDataError({ data: value, init }),
47
+ /** Throw: short-circuit to the error boundary with `data(value, init)`. */
48
+ throw: <D>(value: D, init?: number | ResponseInit) =>
49
+ new ThrowableDataError({ data: value, init }),
50
+ /** Throw: short-circuit with a redirect `Response`. */
51
+ redirect: (url: string, init?: number | ResponseInit) =>
52
+ new ThrowableRedirectError({ url, init }),
53
+ };
54
+
55
+ /** Narrows an unknown value to one of the library's route errors. */
56
+ export const isRouteError = (value: unknown): value is AnyRouteError =>
57
+ value instanceof ReturnableDataError ||
58
+ value instanceof ThrowableDataError ||
59
+ value instanceof ThrowableRedirectError;
package/src/factory.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { Effect } from "effect";
2
+ import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
3
+ import {
4
+ data,
5
+ redirect,
6
+ type ActionFunctionArgs,
7
+ type UNSAFE_DataWithResponseInit as DataWithResponseInit,
8
+ type LoaderFunctionArgs,
9
+ } from "react-router";
10
+
11
+ import {
12
+ isRouteError,
13
+ ReturnableDataError,
14
+ ThrowableDataError,
15
+ type AnyRouteError,
16
+ } from "./errors.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Type-level plumbing.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ type Tagged = { readonly _tag: string };
23
+
24
+ /**
25
+ * Everything a failure branch rejects with. React Router routes a thrown
26
+ * `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
27
+ * `Response` (including `redirect(...)`) as-is. Pinning every `Effect.fail` branch
28
+ * to this union keeps inference from fixing the error channel to the first one.
29
+ */
30
+ type FailureResponse = DataWithResponseInit<unknown> | Response;
31
+
32
+ /**
33
+ * What a handler may return, to *remap* a domain error:
34
+ * - a **library route error** — `Respond.early(value)` (recover), `Respond.throw(data)`
35
+ * or `Respond.redirect(url)` (throw) — which the library then processes; or
36
+ * - an **`Effect`** — `Effect.succeed(value)` to recover with `value`, or
37
+ * `Effect.fail(response)` to throw `response`.
38
+ */
39
+ export type ErrorHandler<Err> = (
40
+ error: Err,
41
+ ) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
42
+
43
+ /** The body of every `ReturnableDataError` raised directly in `E` (`Respond.early`). */
44
+ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : never;
45
+
46
+ /**
47
+ * The recover contribution of a `Respond.early` raised directly in the loader.
48
+ * Guarded so a loader that never recovers directly doesn't leak
49
+ * `DataWithResponseInit<never>` into its return type.
50
+ */
51
+ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
52
+ ? never
53
+ : DataWithResponseInit<ReturnableBodyOf<E>>;
54
+
55
+ /** The domain errors a handler map registers — derived from the handlers' params. */
56
+ type RegisteredError<Handlers> = {
57
+ [Tag in keyof Handlers]: Handlers[Tag] extends (error: infer Err) => unknown ? Err : never;
58
+ }[keyof Handlers];
59
+
60
+ /**
61
+ * What the registered handlers recover into loader/action data — derived from the
62
+ * handlers' returns, but only for handlers whose error can actually occur in `E`:
63
+ * - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
64
+ * - an `Effect.succeed(value)` recovers as `value`;
65
+ * - throwables, redirects and `Effect.fail(...)` contribute nothing.
66
+ */
67
+ type RecoverOf<Handlers, E> = {
68
+ [Tag in keyof Handlers]: Handlers[Tag] extends (error: infer Err) => infer R
69
+ ? [Extract<E, Err>] extends [never]
70
+ ? never
71
+ :
72
+ | (R extends ReturnableDataError<infer B> ? DataWithResponseInit<B> : never)
73
+ | (R extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never)
74
+ : never;
75
+ }[keyof Handlers];
76
+
77
+ /** Shape every handler must satisfy: return a library route error or an `Effect` failing with a response. */
78
+ type ValidHandlers = Record<
79
+ string,
80
+ (error: never) => AnyRouteError | Effect.Effect<unknown, FailureResponse>
81
+ >;
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Internal runtime helpers.
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const internalServerError = () =>
88
+ Effect.fail<FailureResponse>(new Response("Internal Server Error", { status: 500 }));
89
+
90
+ /** The internal dispatch for a library route error: recover, throw, or redirect. */
91
+ const processRouteError = (
92
+ e: AnyRouteError,
93
+ ): Effect.Effect<DataWithResponseInit<unknown>, FailureResponse> => {
94
+ if (e instanceof ReturnableDataError) return Effect.succeed(data(e.data, e.init));
95
+ if (e instanceof ThrowableDataError) return Effect.fail<FailureResponse>(data(e.data, e.init));
96
+ return Effect.fail<FailureResponse>(redirect(e.url, e.init));
97
+ };
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Factory.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
105
+ *
106
+ * Register a handler per domain error in `errorHandlers` (**annotate each handler's
107
+ * param** — the registered set and recover types are derived from the map). A
108
+ * handler *remaps* the error by returning either a library route error
109
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
110
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
111
+ * throw). Recovered values become loader/action data, typed precisely (non-generic).
112
+ *
113
+ * Handling is optional: an *unregistered* error is allowed. If it implements
114
+ * `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
115
+ * error boundary as a 500.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
120
+ * errorHandlers: {
121
+ * // throw → error boundary
122
+ * MyDomainError: (error: MyDomainError) =>
123
+ * Effect.fail(new Response(error.message, { status: 400 })),
124
+ * // remap → recover via a library returnable
125
+ * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
126
+ * },
127
+ * });
128
+ * ```
129
+ */
130
+ export function makeLoaderOrActionFactory<const Handlers>(
131
+ config: { errorHandlers: Handlers },
132
+ // Validation: a handler that doesn't return a route error or a failing `Effect`
133
+ // makes this rest parameter required, forcing a compile error at the call.
134
+ ..._validate: Handlers extends ValidHandlers
135
+ ? []
136
+ : [eachHandlerMustReturnARouteErrorOrAnEffectFailingWithAResponse: ValidHandlers]
137
+ ) {
138
+ /** Domain errors this factory has handlers for. */
139
+ type UserError = Extract<RegisteredError<Handlers>, Tagged>;
140
+
141
+ const isUserError = (e: unknown): e is UserError =>
142
+ typeof e === "object" &&
143
+ e !== null &&
144
+ "_tag" in e &&
145
+ (e as Tagged)._tag in (config.errorHandlers as object);
146
+
147
+ // Uniform call signature for dispatch (the per-tag handler types are narrower).
148
+ const userHandlers = config.errorHandlers as unknown as Record<string, ErrorHandler<UserError>>;
149
+
150
+ function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
151
+ fn: (args: Args) => Effect.Effect<A, E>,
152
+ // The resolved value: the loader's own success, the body of any `Respond.early`
153
+ // raised directly, plus everything the registered (and reachable) handlers
154
+ // recover with.
155
+ ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
156
+ return (args: Args) =>
157
+ // The internal channel is deliberately loose (`unknown` success); the outer
158
+ // cast restores the precise resolved type (computed from `E` and the handler
159
+ // map). Sound at runtime — the values produced are exactly those types.
160
+ Effect.runPromise(
161
+ fn(args).pipe(
162
+ // Catch the whole error channel and dispatch. The refinement is `e is E`
163
+ // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
164
+ // overload crashes tsc over a generic `E`). Unregistered, non-respondable
165
+ // errors fall through to the 500 default.
166
+ Effect.catchIf(
167
+ (_e): _e is E => true,
168
+ (e): Effect.Effect<unknown, FailureResponse> => {
169
+ // Registered domain error → remap. A library-error return is processed
170
+ // by the internal dispatch; an `Effect` return is used as-is.
171
+ if (isUserError(e)) {
172
+ const out = userHandlers[e._tag](e);
173
+ return isRouteError(out) ? processRouteError(out) : out;
174
+ }
175
+ // A library route error raised directly in the loader → recover/throw.
176
+ if (isRouteError(e)) return processRouteError(e);
177
+ // Respondable → render its own response and throw it.
178
+ if (HttpServerRespondable.isRespondable(e)) {
179
+ return HttpServerRespondable.toResponse(e).pipe(
180
+ Effect.flatMap((res) =>
181
+ Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
182
+ ),
183
+ );
184
+ }
185
+ return internalServerError();
186
+ },
187
+ ),
188
+ ),
189
+ ) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
190
+ }
191
+
192
+ return {
193
+ makeLoaderOrAction,
194
+ makeLoader: makeLoaderOrAction,
195
+ makeAction: makeLoaderOrAction,
196
+ };
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ isRouteError,
3
+ Respond,
4
+ ReturnableDataError,
5
+ ThrowableDataError,
6
+ ThrowableRedirectError,
7
+ } from "./errors.ts";
8
+ export type { AnyRouteError } from "./errors.ts";
9
+ export { makeLoaderOrActionFactory } from "./factory.ts";
10
+ export type { ErrorHandler } from "./factory.ts";