react-router-effect 0.5.0 → 0.6.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
@@ -64,7 +64,7 @@ handling of feature/service-specific ones.
64
64
  ```ts
65
65
  // app/route.server.ts
66
66
  import { Data, Effect } from "effect";
67
- import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-effect";
67
+ import { makeLoaderOrActionFactory } from "react-router-effect";
68
68
 
69
69
  class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
70
70
  class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
@@ -74,27 +74,31 @@ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
74
74
  // so it falls through to the 500 default.
75
75
  type DomainErrors = FormError | BadInputError | DbError;
76
76
 
77
- // Curried: pin the domain errors, then the handler types are inferred.
78
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
79
- errorHandlers: {
80
- // keys autocomplete to your domain-error tags; `error` is typed from its key.
81
- // recover: short-circuit and hand the reply to the component
82
- FormError: (error) => baseRespond.early({ reply: error.reply }),
83
- // throw: send to the error boundary
84
- BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
85
- },
86
- });
87
-
88
- // Extend Respond with app-specific helpers if you like:
89
- export const Respond = {
90
- ...baseRespond,
91
- formError: (reply: SubmissionResponse) => new FormError({ reply }),
92
- };
77
+ // Curried: pin the domain errors, then the handler types are inferred. The config
78
+ // is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
79
+ // so your handlers can recover/throw, and the factory hands back a single `Respond`
80
+ // for your routes to import (no auto-import ambiguity with a library export).
81
+ export const { makeLoader, makeAction, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
82
+ (Respond) => ({
83
+ // App-specific helpers — merged onto the returned `Respond` (base helpers win):
84
+ respond: {
85
+ formError: (reply: SubmissionResponse) => new FormError({ reply }),
86
+ },
87
+ errorHandlers: {
88
+ // keys autocomplete to your domain-error tags; `error` is typed from its key.
89
+ // recover: short-circuit and hand the reply to the component
90
+ FormError: (error) => Respond.early({ reply: error.reply }),
91
+ // throw: send to the error boundary
92
+ BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
93
+ },
94
+ }),
95
+ );
93
96
  ```
94
97
 
95
- > **No annotations needed.** Handler keys autocomplete to your declared domain-error tags, each
96
- > `error` parameter is typed from its key, and the precise recover types are derived from each
97
- > handler's return.
98
+ > **One `Respond`, no annotations.** Your routes import the `Respond` returned here — there's
99
+ > no library-level `Respond` to clash with it on auto-import. Handler keys autocomplete to your
100
+ > declared domain-error tags, each `error` parameter is typed from its key, and the precise
101
+ > recover types are derived from each handler's return.
98
102
 
99
103
  ### 2. Write loaders/actions as effects
100
104
 
@@ -153,10 +157,10 @@ import { ManagedRuntime } from "effect";
153
157
  export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
154
158
 
155
159
  // app/route.server.ts
156
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
160
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
157
161
  runtime: appRuntime,
158
162
  errorHandlers: { ... },
159
- });
163
+ }));
160
164
 
161
165
  // app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
162
166
  const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -174,8 +178,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
174
178
  `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
175
179
 
176
180
  ```ts
177
- makeLoaderOrActionFactory()({ runtime });
178
- makeLoaderOrActionFactory()({});
181
+ makeLoaderOrActionFactory()(() => ({ runtime }));
182
+ makeLoaderOrActionFactory()(() => ({}));
179
183
  ```
180
184
 
181
185
  ### Per-request services from middleware
@@ -210,10 +214,10 @@ export const middleware: Route.MiddlewareFunction[] = [
210
214
  ];
211
215
 
212
216
  // app/route.server.ts — wire the same key into the factory:
213
- export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
217
+ export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
214
218
  runtime: appRuntime,
215
219
  requestContext,
216
- });
220
+ }));
217
221
 
218
222
  // the loader requires RequestContext directly — no provide, fresh each request:
219
223
  export const loader = makeLoader(() =>
@@ -230,13 +234,16 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
230
234
 
231
235
  ## API
232
236
 
233
- - **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime?, requestContext? })`** →
234
- `{ makeLoader, makeAction }` (both are the same wrapper). All config fields are optional. A
235
- non-domain error left in a loader/action's error channel — or a required service that neither the
236
- `runtime` nor the `requestContext` provides — is a compile error.
237
+ - **`makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
238
+ → `{ makeLoader, makeAction, Respond }` (the two makers are the same wrapper). The config is a
239
+ _builder_ that receives the base `Respond`; all its fields are optional. The returned `Respond` is
240
+ the base helpers merged with your `respond` extensions (base helpers win). A non-domain error left
241
+ in a loader/action's error channel — or a required service that neither the `runtime` nor the
242
+ `requestContext` provides — is a compile error.
237
243
  - **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
238
244
  effect context (`RouterContext<Context.Context<ReqServices>>`).
239
- - **`Respond`** — `early` (recover), `throw`, `redirect`.
245
+ - **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
246
+ `respond` helpers you add.
240
247
  - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
241
248
  route errors, and **`isRouteError`** to narrow them.
242
249
  - **`ErrorHandler<Err>`** — the handler signature type.
package/dist/index.d.mts CHANGED
@@ -30,15 +30,18 @@ declare class ThrowableRedirectError extends ThrowableRedirectError_base<{
30
30
  /** Every library route error, as a single union. */
31
31
  type AnyRouteError = ReturnableDataError<unknown> | ThrowableDataError<unknown> | ThrowableRedirectError;
32
32
  /**
33
- * Standardized helpers for raising the library's route errors. Consumers extend it
34
- * by spreading into their own object — opinionated helpers (e.g. a form-library
35
- * `formError`) live in the consumer, not here:
33
+ * The library's base helpers for raising route errors (`early` / `throw` /
34
+ * `redirect`). This is internal: it isn't exported from the package. The factory
35
+ * hands it to your config builder and returns it (merged with any `respond`
36
+ * extensions) so your app imports a single `Respond`:
36
37
  *
37
38
  * ```ts
38
- * export const Respond = {
39
- * ...baseRespond,
40
- * formError: (reply) => baseRespond.early({ reply }, 400),
41
- * };
39
+ * export const { makeLoader, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
40
+ * (Respond) => ({
41
+ * respond: { formError: (reply) => new FormError({ reply }) },
42
+ * errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
43
+ * }),
44
+ * );
42
45
  * ```
43
46
  */
44
47
  declare const Respond: {
@@ -50,6 +53,8 @@ declare const Respond: {
50
53
  declare const isRouteError: (value: unknown) => value is AnyRouteError;
51
54
  //#endregion
52
55
  //#region src/factory.d.ts
56
+ /** The library's base `Respond` helpers (`early` / `throw` / `redirect`). */
57
+ type BaseRespond = typeof Respond;
53
58
  type Tagged = {
54
59
  readonly _tag: string;
55
60
  };
@@ -159,15 +164,21 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
159
164
  * ```ts
160
165
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
161
166
  *
162
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
163
- * errorHandlers: {
164
- * // `error` is typed as MyDomainError automatically. throw → error boundary
165
- * MyDomainError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
166
- * // DbError has no handler → falls through to the 500 default.
167
- * },
168
- * });
167
+ * export const { makeLoader, makeAction, Respond } =
168
+ * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
169
+ * errorHandlers: {
170
+ * // `error` is typed as MyDomainError automatically. throw → error boundary
171
+ * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
172
+ * // DbError has no handler → falls through to the 500 default.
173
+ * },
174
+ * }));
169
175
  * ```
170
176
  *
177
+ * The inner config is a **builder** — it receives the library's `Respond` helpers so
178
+ * your handlers can recover/throw with them, and returns the config. `Respond` is
179
+ * also returned from the factory (extended with any `respond` helpers you add), so
180
+ * your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
181
+ *
171
182
  * A handler *remaps* the error by returning either a library route error
172
183
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
173
184
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -184,10 +195,10 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
184
195
  * services directly — no per-call `Effect.provide`:
185
196
  *
186
197
  * ```ts
187
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
198
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
188
199
  * runtime: getAppRuntime(), // provides Database, MyService, ...
189
200
  * errorHandlers: { ... },
190
- * });
201
+ * }));
191
202
  *
192
203
  * // `MyService` is satisfied by the runtime, not provided here:
193
204
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -198,13 +209,13 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
198
209
  * );
199
210
  * ```
200
211
  */
201
- declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never>(config: {
212
+ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never, const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {}>(builder: (respond: BaseRespond) => {
202
213
  /**
203
214
  * An optional handler per declared domain error, keyed by its tag. The keys
204
215
  * autocomplete to your domain-error tags and each handler's `error` parameter
205
216
  * is typed automatically — no annotation needed. A handler *remaps* the error
206
- * by returning a library route error (`Respond.early` / `throw` / `redirect`)
207
- * or an `Effect` (`Effect.succeed`/`fail`). Omit entirely to register none.
217
+ * by returning a library route error (the builder's `Respond.early` / `throw`
218
+ * / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
208
219
  */
209
220
  errorHandlers?: ErrorHandlers<DomainError, Returns>;
210
221
  /**
@@ -222,9 +233,22 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
222
233
  * `ReqServices` (inferred from here) in addition to the runtime's services.
223
234
  */
224
235
  requestContext?: RequestContextKey<ReqServices>;
236
+ /**
237
+ * App-specific `Respond` helpers — merged onto the base `Respond` that the
238
+ * factory returns, so your app imports one `Respond`. Typically opinionated
239
+ * error constructors; annotate their parameters, e.g.
240
+ * `{ formError: (reply: string) => new FormError({ reply }) }`. The base
241
+ * helpers (`early` / `throw` / `redirect`) always win the merge.
242
+ */
243
+ respond?: ExtraRespond;
225
244
  }) => {
226
245
  makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
227
246
  makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
247
+ Respond: {
248
+ early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
249
+ throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
250
+ redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
251
+ } & ExtraRespond;
228
252
  };
229
253
  //#endregion
230
- export { type AnyRouteError, type ErrorHandler, type RequestContextKey, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
254
+ export { type AnyRouteError, type ErrorHandler, type RequestContextKey, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
package/dist/index.mjs CHANGED
@@ -9,15 +9,18 @@ var ThrowableDataError = class extends Data.TaggedError("ThrowableRouteError") {
9
9
  /** Throwable: rejects with a redirect `Response`. */
10
10
  var ThrowableRedirectError = class extends Data.TaggedError("ThrowableRedirectError") {};
11
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:
12
+ * The library's base helpers for raising route errors (`early` / `throw` /
13
+ * `redirect`). This is internal: it isn't exported from the package. The factory
14
+ * hands it to your config builder and returns it (merged with any `respond`
15
+ * extensions) so your app imports a single `Respond`:
15
16
  *
16
17
  * ```ts
17
- * export const Respond = {
18
- * ...baseRespond,
19
- * formError: (reply) => baseRespond.early({ reply }, 400),
20
- * };
18
+ * export const { makeLoader, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
19
+ * (Respond) => ({
20
+ * respond: { formError: (reply) => new FormError({ reply }) },
21
+ * errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
22
+ * }),
23
+ * );
21
24
  * ```
22
25
  */
23
26
  const Respond = {
@@ -60,15 +63,21 @@ const processRouteError = (e) => {
60
63
  * ```ts
61
64
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
62
65
  *
63
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
64
- * errorHandlers: {
65
- * // `error` is typed as MyDomainError automatically. throw → error boundary
66
- * MyDomainError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
67
- * // DbError has no handler → falls through to the 500 default.
68
- * },
69
- * });
66
+ * export const { makeLoader, makeAction, Respond } =
67
+ * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
68
+ * errorHandlers: {
69
+ * // `error` is typed as MyDomainError automatically. throw → error boundary
70
+ * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
71
+ * // DbError has no handler → falls through to the 500 default.
72
+ * },
73
+ * }));
70
74
  * ```
71
75
  *
76
+ * The inner config is a **builder** — it receives the library's `Respond` helpers so
77
+ * your handlers can recover/throw with them, and returns the config. `Respond` is
78
+ * also returned from the factory (extended with any `respond` helpers you add), so
79
+ * your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
80
+ *
72
81
  * A handler *remaps* the error by returning either a library route error
73
82
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
74
83
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -85,10 +94,10 @@ const processRouteError = (e) => {
85
94
  * services directly — no per-call `Effect.provide`:
86
95
  *
87
96
  * ```ts
88
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
97
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
89
98
  * runtime: getAppRuntime(), // provides Database, MyService, ...
90
99
  * errorHandlers: { ... },
91
- * });
100
+ * }));
92
101
  *
93
102
  * // `MyService` is satisfied by the runtime, not provided here:
94
103
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -100,9 +109,14 @@ const processRouteError = (e) => {
100
109
  * ```
101
110
  */
102
111
  function makeLoaderOrActionFactory() {
103
- return function defineErrorHandlers(config) {
112
+ return function defineErrorHandlers(builder) {
113
+ const config = builder(Respond);
104
114
  const runtime = config.runtime;
105
115
  const requestContextKey = config.requestContext;
116
+ const Respond$1 = {
117
+ ...config.respond,
118
+ ...Respond
119
+ };
106
120
  const userHandlers = config.errorHandlers ?? {};
107
121
  const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
108
122
  function makeLoaderOrAction(fn, ..._handle) {
@@ -122,9 +136,10 @@ function makeLoaderOrActionFactory() {
122
136
  }
123
137
  return {
124
138
  makeLoader: makeLoaderOrAction,
125
- makeAction: makeLoaderOrAction
139
+ makeAction: makeLoaderOrAction,
140
+ Respond: Respond$1
126
141
  };
127
142
  };
128
143
  }
129
144
  //#endregion
130
- export { Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
145
+ export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
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.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
6
6
  "keywords": [
7
7
  "action",
package/src/errors.ts CHANGED
@@ -29,15 +29,18 @@ export type AnyRouteError =
29
29
  | ThrowableRedirectError;
30
30
 
31
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:
32
+ * The library's base helpers for raising route errors (`early` / `throw` /
33
+ * `redirect`). This is internal: it isn't exported from the package. The factory
34
+ * hands it to your config builder and returns it (merged with any `respond`
35
+ * extensions) so your app imports a single `Respond`:
35
36
  *
36
37
  * ```ts
37
- * export const Respond = {
38
- * ...baseRespond,
39
- * formError: (reply) => baseRespond.early({ reply }, 400),
40
- * };
38
+ * export const { makeLoader, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
39
+ * (Respond) => ({
40
+ * respond: { formError: (reply) => new FormError({ reply }) },
41
+ * errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
42
+ * }),
43
+ * );
41
44
  * ```
42
45
  */
43
46
  export const Respond = {
package/src/factory.ts CHANGED
@@ -11,11 +11,15 @@ import {
11
11
 
12
12
  import {
13
13
  isRouteError,
14
+ Respond as baseRespond,
14
15
  ReturnableDataError,
15
16
  ThrowableDataError,
16
17
  type AnyRouteError,
17
18
  } from "./errors.ts";
18
19
 
20
+ /** The library's base `Respond` helpers (`early` / `throw` / `redirect`). */
21
+ type BaseRespond = typeof baseRespond;
22
+
19
23
  // ---------------------------------------------------------------------------
20
24
  // Type-level plumbing.
21
25
  // ---------------------------------------------------------------------------
@@ -173,15 +177,21 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
173
177
  * ```ts
174
178
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
175
179
  *
176
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
177
- * errorHandlers: {
178
- * // `error` is typed as MyDomainError automatically. throw → error boundary
179
- * MyDomainError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
180
- * // DbError has no handler → falls through to the 500 default.
181
- * },
182
- * });
180
+ * export const { makeLoader, makeAction, Respond } =
181
+ * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
182
+ * errorHandlers: {
183
+ * // `error` is typed as MyDomainError automatically. throw → error boundary
184
+ * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
185
+ * // DbError has no handler → falls through to the 500 default.
186
+ * },
187
+ * }));
183
188
  * ```
184
189
  *
190
+ * The inner config is a **builder** — it receives the library's `Respond` helpers so
191
+ * your handlers can recover/throw with them, and returns the config. `Respond` is
192
+ * also returned from the factory (extended with any `respond` helpers you add), so
193
+ * your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
194
+ *
185
195
  * A handler *remaps* the error by returning either a library route error
186
196
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
187
197
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -198,10 +208,10 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
198
208
  * services directly — no per-call `Effect.provide`:
199
209
  *
200
210
  * ```ts
201
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
211
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
202
212
  * runtime: getAppRuntime(), // provides Database, MyService, ...
203
213
  * errorHandlers: { ... },
204
- * });
214
+ * }));
205
215
  *
206
216
  * // `MyService` is satisfied by the runtime, not provided here:
207
217
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -217,33 +227,53 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
217
227
  const Returns extends HandlerReturns<DomainError> = {},
218
228
  RServices = never,
219
229
  ReqServices = never,
220
- >(config: {
221
- /**
222
- * An optional handler per declared domain error, keyed by its tag. The keys
223
- * autocomplete to your domain-error tags and each handler's `error` parameter
224
- * is typed automatically — no annotation needed. A handler *remaps* the error
225
- * by returning a library route error (`Respond.early` / `throw` / `redirect`)
226
- * or an `Effect` (`Effect.succeed`/`fail`). Omit entirely to register none.
227
- */
228
- errorHandlers?: ErrorHandlers<DomainError, Returns>;
229
- /**
230
- * The app runtime that provides services to loader/action effects. When
231
- * set, effects may require its services (`RServices`, inferred from here)
232
- * without providing layers, and runs go through `runtime.runPromise`. When
233
- * omitted, `RServices` is `never` and effects must require nothing.
234
- */
235
- runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
230
+ const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {},
231
+ >(
236
232
  /**
237
- * A React Router context key (a {@link RequestContextKey}) holding a
238
- * per-request effect `Context.Context`. Middleware sets it for each request;
239
- * the runner reads `args.context.get(requestContext)` and provides those
240
- * services to the effect. Loader/action effects may then require
241
- * `ReqServices` (inferred from here) in addition to the runtime's services.
233
+ * Builds the factory config. It receives the library's base `Respond` helpers
234
+ * (`early` / `throw` / `redirect`) so your `errorHandlers` can recover/throw
235
+ * with them, and returns the config object.
242
236
  */
243
- requestContext?: RequestContextKey<ReqServices>;
244
- }) {
237
+ builder: (respond: BaseRespond) => {
238
+ /**
239
+ * An optional handler per declared domain error, keyed by its tag. The keys
240
+ * autocomplete to your domain-error tags and each handler's `error` parameter
241
+ * is typed automatically — no annotation needed. A handler *remaps* the error
242
+ * by returning a library route error (the builder's `Respond.early` / `throw`
243
+ * / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
244
+ */
245
+ errorHandlers?: ErrorHandlers<DomainError, Returns>;
246
+ /**
247
+ * The app runtime that provides services to loader/action effects. When
248
+ * set, effects may require its services (`RServices`, inferred from here)
249
+ * without providing layers, and runs go through `runtime.runPromise`. When
250
+ * omitted, `RServices` is `never` and effects must require nothing.
251
+ */
252
+ runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
253
+ /**
254
+ * A React Router context key (a {@link RequestContextKey}) holding a
255
+ * per-request effect `Context.Context`. Middleware sets it for each request;
256
+ * the runner reads `args.context.get(requestContext)` and provides those
257
+ * services to the effect. Loader/action effects may then require
258
+ * `ReqServices` (inferred from here) in addition to the runtime's services.
259
+ */
260
+ requestContext?: RequestContextKey<ReqServices>;
261
+ /**
262
+ * App-specific `Respond` helpers — merged onto the base `Respond` that the
263
+ * factory returns, so your app imports one `Respond`. Typically opinionated
264
+ * error constructors; annotate their parameters, e.g.
265
+ * `{ formError: (reply: string) => new FormError({ reply }) }`. The base
266
+ * helpers (`early` / `throw` / `redirect`) always win the merge.
267
+ */
268
+ respond?: ExtraRespond;
269
+ },
270
+ ) {
271
+ const config = builder(baseRespond);
245
272
  const runtime = config.runtime;
246
273
  const requestContextKey = config.requestContext;
274
+ // The single `Respond` an app uses: base helpers plus any app-specific ones.
275
+ // Base wins the merge so core helpers can't be shadowed.
276
+ const Respond = { ...config.respond, ...baseRespond } as BaseRespond & ExtraRespond;
247
277
 
248
278
  // Uniform call signature for dispatch (the per-tag handler types are narrower).
249
279
  // Defaults to an empty map when `errorHandlers` is omitted.
@@ -327,6 +357,7 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
327
357
  return {
328
358
  makeLoader: makeLoaderOrAction,
329
359
  makeAction: makeLoaderOrAction,
360
+ Respond,
330
361
  };
331
362
  };
332
363
  }
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export {
2
2
  isRouteError,
3
- Respond,
4
3
  ReturnableDataError,
5
4
  ThrowableDataError,
6
5
  ThrowableRedirectError,