react-router-effect 0.1.0 → 0.3.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 CHANGED
@@ -39,18 +39,23 @@ A loader/action effect can short-circuit in three ways, via the `Respond` helper
39
39
  | `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
40
40
  | `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
41
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:
42
+ You declare your app's **domain errors** as a type argument to the factory, and may register a
43
+ handler per domain error. A handler _remaps_ an error by returning either:
44
44
 
45
45
  - a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
46
46
  - an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
47
47
  to **throw** a `Response` / `DataWithResponseInit`.
48
48
 
49
- Handling is **optional**:
49
+ A **declared domain error** may be left unhandled:
50
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**.
51
+ - if it implements [`HttpServerRespondable`](https://effect.website) it's rendered automatically
52
+ from its own response;
53
+ - otherwise it falls through to a **500**.
54
+
55
+ **Any other error** a route consumes — a service-specific error that isn't a declared domain
56
+ error — **must be handled** in the loader/action (caught or mapped), or `makeLoader`/`makeAction`
57
+ fails to type-check. This gives app-wide defaults for declared errors while enforcing explicit
58
+ handling of feature/service-specific ones.
54
59
 
55
60
  ## Usage
56
61
 
@@ -63,8 +68,14 @@ import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-
63
68
 
64
69
  class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
65
70
  class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
71
+ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
72
+
73
+ // Declare every error your app handles app-wide. `DbError` has no handler below,
74
+ // so it falls through to the 500 default.
75
+ type DomainErrors = FormError | BadInputError | DbError;
66
76
 
67
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory({
77
+ // Curried: pin the domain errors, then the handler types are inferred.
78
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
68
79
  errorHandlers: {
69
80
  // recover: short-circuit and hand the reply to the component
70
81
  FormError: (error: FormError) => baseRespond.early({ reply: error.reply }),
@@ -81,8 +92,8 @@ export const Respond = {
81
92
  };
82
93
  ```
83
94
 
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.
95
+ > **Annotate each handler's parameter.** Handlers must be keyed by a declared domain error, and
96
+ > the precise recover types are derived from the handler map's parameter and return types.
86
97
 
87
98
  ### 2. Write loaders/actions as effects
88
99
 
@@ -129,10 +140,49 @@ class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
129
140
 
130
141
  A registered handler, if present, still takes precedence over the error's own response.
131
142
 
143
+ ### Providing services with a runtime
144
+
145
+ Pass a `runtime` (an effect [`ManagedRuntime`](https://effect.website)) and your loaders/actions
146
+ may require its services directly — no per-call `Effect.provide`. The runtime is built once and
147
+ reused for every request:
148
+
149
+ ```ts
150
+ // app/runtime.server.ts
151
+ import { ManagedRuntime } from "effect";
152
+ export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
153
+
154
+ // app/route.server.ts
155
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
156
+ runtime: appRuntime,
157
+ errorHandlers: { ... },
158
+ });
159
+
160
+ // app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
161
+ const loader = makeLoader((args: Route.LoaderArgs) =>
162
+ Effect.gen(function* () {
163
+ const svc = yield* MyService;
164
+ return { profile: yield* svc.load(args.params.id) };
165
+ }),
166
+ );
167
+ ```
168
+
169
+ The runtime's services become the effect's allowed requirement channel: requiring a service the
170
+ runtime provides type-checks, while requiring one it _doesn't_ is a compile error. With no
171
+ `runtime`, effects must require nothing.
172
+
173
+ `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
174
+
175
+ ```ts
176
+ makeLoaderOrActionFactory()({ runtime });
177
+ makeLoaderOrActionFactory()({});
178
+ ```
179
+
132
180
  ## API
133
181
 
134
- - **`makeLoaderOrActionFactory({ errorHandlers })`** → `{ makeLoader, makeAction, makeLoaderOrAction }`
135
- (the three are the same wrapper under different names).
182
+ - **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime? })`** →
183
+ `{ makeLoader, makeAction }` (both are the same wrapper). Both config fields are optional. A
184
+ non-domain error left in a loader/action's error channel — or a required service the `runtime`
185
+ doesn't provide — is a compile error.
136
186
  - **`Respond`** — `early` (recover), `throw`, `redirect`.
137
187
  - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
138
188
  route errors, and **`isRouteError`** to narrow them.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { Effect } from "effect";
1
+ import { Effect, ManagedRuntime } from "effect";
2
+ import { HttpServerRespondable } from "effect/unstable/http";
2
3
  import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
3
4
 
4
5
  //#region src/errors.d.ts
@@ -49,6 +50,9 @@ declare const Respond: {
49
50
  declare const isRouteError: (value: unknown) => value is AnyRouteError;
50
51
  //#endregion
51
52
  //#region src/factory.d.ts
53
+ type Tagged = {
54
+ readonly _tag: string;
55
+ };
52
56
  /**
53
57
  * Everything a failure branch rejects with. React Router routes a thrown
54
58
  * `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
@@ -80,41 +84,104 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_D
80
84
  * - throwables, redirects and `Effect.fail(...)` contribute nothing.
81
85
  */
82
86
  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>>;
87
+ /**
88
+ * Errors the library deals with on the loader's behalf — so the loader needn't
89
+ * handle them itself:
90
+ * - the app's **declared domain errors** (`DomainError`) — handled by a registered
91
+ * handler, or left to the 500 / auto-respond default;
92
+ * - **library route errors** raised directly via `Respond`;
93
+ * - anything that renders itself via **`HttpServerRespondable`**.
94
+ */
95
+ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
96
+ /**
97
+ * What remains in a loader/action's error channel that the library will NOT handle
98
+ * — i.e. service-specific errors the route consumes that aren't declared domain
99
+ * errors. The loader/action must handle these itself (catch or map them).
100
+ */
101
+ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
102
+ /**
103
+ * The shape a handler map must satisfy: an OPTIONAL handler per declared domain
104
+ * error, keyed by its tag, taking that error and returning a library route error
105
+ * or an `Effect` failing with a response. Used only as a validation constraint —
106
+ * the concrete handler types stay precise via the `const Handlers` inference.
107
+ */
108
+ type ValidHandlers<DomainError extends Tagged> = { [Tag in DomainError["_tag"]]?: (error: Extract<DomainError, {
109
+ readonly _tag: Tag;
110
+ }>) => AnyRouteError | Effect.Effect<unknown, FailureResponse> };
111
+ /**
112
+ * True when every registered handler is keyed by a declared domain error's tag and
113
+ * returns a library route error or a failing `Effect`. A handler for an unknown tag
114
+ * (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
115
+ */
116
+ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [DomainError["_tag"]] ? Handlers extends ValidHandlers<DomainError> ? true : false : false;
85
117
  /**
86
118
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
87
119
  *
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.
120
+ * Declare the app's **domain errors** as the type argument, then register an
121
+ * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
122
+ * param**). It's curried so you can pin the domain errors while the handler types
123
+ * are still inferred:
98
124
  *
99
- * @example
100
125
  * ```ts
101
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
126
+ * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
127
+ *
128
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
102
129
  * errorHandlers: {
103
130
  * // throw → error boundary
104
131
  * MyDomainError: (error: MyDomainError) =>
105
132
  * Effect.fail(new Response(error.message, { status: 400 })),
106
- * // remaprecover via a library returnable
107
- * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
133
+ * // DbError has no handler falls through to the 500 default.
108
134
  * },
109
135
  * });
110
136
  * ```
137
+ *
138
+ * A handler *remaps* the error by returning either a library route error
139
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
140
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
141
+ * throw). Recovered values become loader/action data, typed precisely.
142
+ *
143
+ * **Declared domain errors** may be left unhandled: if one implements
144
+ * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
145
+ * to a 500. **Any other error** — a service-specific error the route consumes that
146
+ * isn't a declared domain error — *must* be handled in the loader/action, or
147
+ * `makeLoader`/`makeAction` fails to type-check.
148
+ *
149
+ * Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
150
+ * to provide your app's services once. Loader/action effects may then require those
151
+ * services directly — no per-call `Effect.provide`:
152
+ *
153
+ * ```ts
154
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
155
+ * runtime: getAppRuntime(), // provides Database, MyService, ...
156
+ * errorHandlers: { ... },
157
+ * });
158
+ *
159
+ * // `MyService` is satisfied by the runtime, not provided here:
160
+ * const loader = makeLoader((args: Route.LoaderArgs) =>
161
+ * Effect.gen(function* () {
162
+ * const svc = yield* MyService;
163
+ * return { data: yield* svc.load(args) };
164
+ * }),
165
+ * );
166
+ * ```
111
167
  */
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>>;
168
+ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers = {}, RServices = never>(config: {
169
+ /**
170
+ * An optional handler per declared domain error. Omit entirely to register
171
+ * none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
172
+ * when there are no domain errors at all).
173
+ */
174
+ errorHandlers?: Handlers;
175
+ /**
176
+ * The app runtime that provides services to loader/action effects. When
177
+ * set, effects may require its services (`RServices`, inferred from here)
178
+ * without providing layers, and runs go through `runtime.runPromise`. When
179
+ * omitted, `RServices` is `never` and effects must require nothing.
180
+ */
181
+ runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
182
+ }, ..._validate: HandlersAreValid<Handlers, DomainError> extends true ? [] : [eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>]) => {
183
+ makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
184
+ makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
118
185
  };
119
186
  //#endregion
120
187
  export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
package/dist/index.mjs CHANGED
@@ -51,48 +51,77 @@ const processRouteError = (e) => {
51
51
  /**
52
52
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
53
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).
54
+ * Declare the app's **domain errors** as the type argument, then register an
55
+ * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
56
+ * param**). It's curried so you can pin the domain errors while the handler types
57
+ * are still inferred:
60
58
  *
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
59
  * ```ts
67
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
60
+ * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
61
+ *
62
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
68
63
  * errorHandlers: {
69
64
  * // throw → error boundary
70
65
  * MyDomainError: (error: MyDomainError) =>
71
66
  * Effect.fail(new Response(error.message, { status: 400 })),
72
- * // remaprecover via a library returnable
73
- * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
67
+ * // DbError has no handler falls through to the 500 default.
74
68
  * },
75
69
  * });
76
70
  * ```
71
+ *
72
+ * A handler *remaps* the error by returning either a library route error
73
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
74
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
75
+ * throw). Recovered values become loader/action data, typed precisely.
76
+ *
77
+ * **Declared domain errors** may be left unhandled: if one implements
78
+ * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
79
+ * to a 500. **Any other error** — a service-specific error the route consumes that
80
+ * isn't a declared domain error — *must* be handled in the loader/action, or
81
+ * `makeLoader`/`makeAction` fails to type-check.
82
+ *
83
+ * Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
84
+ * to provide your app's services once. Loader/action effects may then require those
85
+ * services directly — no per-call `Effect.provide`:
86
+ *
87
+ * ```ts
88
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
89
+ * runtime: getAppRuntime(), // provides Database, MyService, ...
90
+ * errorHandlers: { ... },
91
+ * });
92
+ *
93
+ * // `MyService` is satisfied by the runtime, not provided here:
94
+ * const loader = makeLoader((args: Route.LoaderArgs) =>
95
+ * Effect.gen(function* () {
96
+ * const svc = yield* MyService;
97
+ * return { data: yield* svc.load(args) };
98
+ * }),
99
+ * );
100
+ * ```
77
101
  */
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
102
+ function makeLoaderOrActionFactory() {
103
+ return function defineErrorHandlers(config, ..._validate) {
104
+ const runtime = config.runtime;
105
+ const userHandlers = config.errorHandlers ?? {};
106
+ const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
107
+ function makeLoaderOrAction(fn, ..._handle) {
108
+ return (args) => {
109
+ const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
110
+ if (isUserError(e)) {
111
+ const out = userHandlers[e._tag](e);
112
+ return isRouteError(out) ? processRouteError(out) : out;
113
+ }
114
+ if (isRouteError(e)) return processRouteError(e);
115
+ if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
116
+ return internalServerError();
117
+ }));
118
+ return runtime ? runtime.runPromise(program) : Effect.runPromise(program);
119
+ };
120
+ }
121
+ return {
122
+ makeLoader: makeLoaderOrAction,
123
+ makeAction: makeLoaderOrAction
124
+ };
96
125
  };
97
126
  }
98
127
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schemastore.org/package.json",
3
3
  "name": "react-router-effect",
4
- "version": "0.1.0",
4
+ "version": "0.3.0",
5
5
  "description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
6
6
  "keywords": [
7
7
  "action",
@@ -48,7 +48,7 @@
48
48
  "playwright": "1.60.0",
49
49
  "react": "^19.2.7",
50
50
  "react-dom": "^19.2.7",
51
- "react-router": "^7.16.0",
51
+ "react-router": "^8.0.0",
52
52
  "typescript": "^6.0.3",
53
53
  "vite-plus": "latest",
54
54
  "vitest": "4.1.9",
@@ -56,13 +56,13 @@
56
56
  },
57
57
  "peerDependencies": {
58
58
  "effect": "^4.0.0-beta.88",
59
- "react-router": "^7.16.0"
59
+ "react-router": "^7.16.0 || ^8.0.0"
60
60
  },
61
61
  "devEngines": {
62
62
  "packageManager": {
63
63
  "name": "pnpm",
64
64
  "version": "11.9.0",
65
- "onFail": "download"
65
+ "onFail": "warn"
66
66
  }
67
67
  },
68
68
  "scripts": {
package/src/factory.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Effect, type ManagedRuntime } from "effect";
2
2
  import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
3
3
  import {
4
4
  data,
@@ -52,11 +52,6 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
52
52
  ? never
53
53
  : DataWithResponseInit<ReturnableBodyOf<E>>;
54
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
55
  /**
61
56
  * What the registered handlers recover into loader/action data — derived from the
62
57
  * handlers' returns, but only for handlers whose error can actually occur in `E`:
@@ -74,11 +69,47 @@ type RecoverOf<Handlers, E> = {
74
69
  : never;
75
70
  }[keyof Handlers];
76
71
 
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
- >;
72
+ /**
73
+ * Errors the library deals with on the loader's behalf — so the loader needn't
74
+ * handle them itself:
75
+ * - the app's **declared domain errors** (`DomainError`) handled by a registered
76
+ * handler, or left to the 500 / auto-respond default;
77
+ * - **library route errors** raised directly via `Respond`;
78
+ * - anything that renders itself via **`HttpServerRespondable`**.
79
+ */
80
+ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
81
+
82
+ /**
83
+ * What remains in a loader/action's error channel that the library will NOT handle
84
+ * — i.e. service-specific errors the route consumes that aren't declared domain
85
+ * errors. The loader/action must handle these itself (catch or map them).
86
+ */
87
+ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
88
+
89
+ /**
90
+ * The shape a handler map must satisfy: an OPTIONAL handler per declared domain
91
+ * error, keyed by its tag, taking that error and returning a library route error
92
+ * or an `Effect` failing with a response. Used only as a validation constraint —
93
+ * the concrete handler types stay precise via the `const Handlers` inference.
94
+ */
95
+ type ValidHandlers<DomainError extends Tagged> = {
96
+ [Tag in DomainError["_tag"]]?: (
97
+ error: Extract<DomainError, { readonly _tag: Tag }>,
98
+ ) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
99
+ };
100
+
101
+ /**
102
+ * True when every registered handler is keyed by a declared domain error's tag and
103
+ * returns a library route error or a failing `Effect`. A handler for an unknown tag
104
+ * (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
105
+ */
106
+ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [
107
+ DomainError["_tag"],
108
+ ]
109
+ ? Handlers extends ValidHandlers<DomainError>
110
+ ? true
111
+ : false
112
+ : false;
82
113
 
83
114
  // ---------------------------------------------------------------------------
84
115
  // Internal runtime helpers.
@@ -103,66 +134,118 @@ const processRouteError = (
103
134
  /**
104
135
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
105
136
  *
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.
137
+ * Declare the app's **domain errors** as the type argument, then register an
138
+ * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
139
+ * param**). It's curried so you can pin the domain errors while the handler types
140
+ * are still inferred:
116
141
  *
117
- * @example
118
142
  * ```ts
119
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
143
+ * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
144
+ *
145
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
120
146
  * errorHandlers: {
121
147
  * // throw → error boundary
122
148
  * MyDomainError: (error: MyDomainError) =>
123
149
  * Effect.fail(new Response(error.message, { status: 400 })),
124
- * // remaprecover via a library returnable
125
- * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
150
+ * // DbError has no handler falls through to the 500 default.
126
151
  * },
127
152
  * });
128
153
  * ```
154
+ *
155
+ * A handler *remaps* the error by returning either a library route error
156
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
157
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
158
+ * throw). Recovered values become loader/action data, typed precisely.
159
+ *
160
+ * **Declared domain errors** may be left unhandled: if one implements
161
+ * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
162
+ * to a 500. **Any other error** — a service-specific error the route consumes that
163
+ * isn't a declared domain error — *must* be handled in the loader/action, or
164
+ * `makeLoader`/`makeAction` fails to type-check.
165
+ *
166
+ * Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
167
+ * to provide your app's services once. Loader/action effects may then require those
168
+ * services directly — no per-call `Effect.provide`:
169
+ *
170
+ * ```ts
171
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
172
+ * runtime: getAppRuntime(), // provides Database, MyService, ...
173
+ * errorHandlers: { ... },
174
+ * });
175
+ *
176
+ * // `MyService` is satisfied by the runtime, not provided here:
177
+ * const loader = makeLoader((args: Route.LoaderArgs) =>
178
+ * Effect.gen(function* () {
179
+ * const svc = yield* MyService;
180
+ * return { data: yield* svc.load(args) };
181
+ * }),
182
+ * );
183
+ * ```
129
184
  */
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(
185
+ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
186
+ return function defineErrorHandlers<const Handlers = {}, RServices = never>(
187
+ config: {
188
+ /**
189
+ * An optional handler per declared domain error. Omit entirely to register
190
+ * none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
191
+ * when there are no domain errors at all).
192
+ */
193
+ errorHandlers?: Handlers;
194
+ /**
195
+ * The app runtime that provides services to loader/action effects. When
196
+ * set, effects may require its services (`RServices`, inferred from here)
197
+ * without providing layers, and runs go through `runtime.runPromise`. When
198
+ * omitted, `RServices` is `never` and effects must require nothing.
199
+ */
200
+ runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
201
+ },
202
+ // Validation: a handler for a non-domain error, or one that doesn't return a
203
+ // route error / failing `Effect`, makes this rest parameter required and forces
204
+ // a compile error at the call.
205
+ ..._validate: HandlersAreValid<Handlers, DomainError> extends true
206
+ ? []
207
+ : [
208
+ eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
209
+ ]
210
+ ) {
211
+ const runtime = config.runtime;
212
+
213
+ // Uniform call signature for dispatch (the per-tag handler types are narrower).
214
+ // Defaults to an empty map when `errorHandlers` is omitted.
215
+ const userHandlers = (config.errorHandlers ?? {}) as unknown as Record<
216
+ string,
217
+ ErrorHandler<DomainError>
218
+ >;
219
+
220
+ const isUserError = (e: unknown): e is DomainError =>
221
+ typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
222
+
223
+ function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
224
+ // The effect may require `RServices` — the services the configured `runtime`
225
+ // provides (or `never` when no runtime is configured, requiring nothing).
226
+ fn: (args: Args) => Effect.Effect<A, E, RServices>,
227
+ // If the effect can still fail with something the library won't handle — a
228
+ // service-specific error that isn't a declared domain error, a library route
229
+ // error, or respondable — this rest parameter becomes required and the call
230
+ // fails to type-check, forcing the loader/action to handle it.
231
+ ..._handle: [Unhandled<E, DomainError>] extends [never]
232
+ ? []
233
+ : [
234
+ theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
235
+ E,
236
+ DomainError
237
+ >,
238
+ ]
239
+ ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
240
+ return (args: Args) => {
241
+ // The internal channel is deliberately loose (`unknown` success); the outer
242
+ // cast restores the precise resolved type (computed from `E` and the handler
243
+ // map). Sound at runtime — the values produced are exactly those types.
244
+ const program = fn(args).pipe(
162
245
  // Catch the whole error channel and dispatch. The refinement is `e is E`
163
246
  // (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.
247
+ // overload crashes tsc over a generic `E`). A declared domain error with
248
+ // no handler (and not respondable) falls through to the 500 default.
166
249
  Effect.catchIf(
167
250
  (_e): _e is E => true,
168
251
  (e): Effect.Effect<unknown, FailureResponse> => {
@@ -185,13 +268,19 @@ export function makeLoaderOrActionFactory<const Handlers>(
185
268
  return internalServerError();
186
269
  },
187
270
  ),
188
- ),
189
- ) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
190
- }
191
-
192
- return {
193
- makeLoaderOrAction,
194
- makeLoader: makeLoaderOrAction,
195
- makeAction: makeLoaderOrAction,
271
+ );
272
+ // Run against the configured runtime so its services satisfy the effect's
273
+ // `R`; with no runtime, the effect requires nothing and runs standalone.
274
+ const result = runtime
275
+ ? runtime.runPromise(program)
276
+ : Effect.runPromise(program as Effect.Effect<unknown, FailureResponse>);
277
+ return result as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
278
+ };
279
+ }
280
+
281
+ return {
282
+ makeLoader: makeLoaderOrAction,
283
+ makeAction: makeLoaderOrAction,
284
+ };
196
285
  };
197
286
  }