react-router-effect 0.4.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,26 +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
- // recover: short-circuit and hand the reply to the component
81
- FormError: (error: FormError) => baseRespond.early({ reply: error.reply }),
82
- // throw: send to the error boundary
83
- BadInputError: (error: BadInputError) =>
84
- 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
- > **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.
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.
97
102
 
98
103
  ### 2. Write loaders/actions as effects
99
104
 
@@ -152,10 +157,10 @@ import { ManagedRuntime } from "effect";
152
157
  export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
153
158
 
154
159
  // app/route.server.ts
155
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
160
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
156
161
  runtime: appRuntime,
157
162
  errorHandlers: { ... },
158
- });
163
+ }));
159
164
 
160
165
  // app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
161
166
  const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -173,8 +178,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
173
178
  `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
174
179
 
175
180
  ```ts
176
- makeLoaderOrActionFactory()({ runtime });
177
- makeLoaderOrActionFactory()({});
181
+ makeLoaderOrActionFactory()(() => ({ runtime }));
182
+ makeLoaderOrActionFactory()(() => ({}));
178
183
  ```
179
184
 
180
185
  ### Per-request services from middleware
@@ -209,10 +214,10 @@ export const middleware: Route.MiddlewareFunction[] = [
209
214
  ];
210
215
 
211
216
  // app/route.server.ts — wire the same key into the factory:
212
- export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
217
+ export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
213
218
  runtime: appRuntime,
214
219
  requestContext,
215
- });
220
+ }));
216
221
 
217
222
  // the loader requires RequestContext directly — no provide, fresh each request:
218
223
  export const loader = makeLoader(() =>
@@ -229,13 +234,16 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
229
234
 
230
235
  ## API
231
236
 
232
- - **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime?, requestContext? })`** →
233
- `{ makeLoader, makeAction }` (both are the same wrapper). All config fields are optional. A
234
- non-domain error left in a loader/action's error channel — or a required service that neither the
235
- `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.
236
243
  - **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
237
244
  effect context (`RouterContext<Context.Context<ReqServices>>`).
238
- - **`Respond`** — `early` (recover), `throw`, `redirect`.
245
+ - **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
246
+ `respond` helpers you add.
239
247
  - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
240
248
  route errors, and **`isRouteError`** to narrow them.
241
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
  };
@@ -76,14 +81,34 @@ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : ne
76
81
  * `DataWithResponseInit<never>` into its return type.
77
82
  */
78
83
  type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_DataWithResponseInit<ReturnableBodyOf<E>>;
84
+ /** What a handler may return: a library route error or an `Effect` failing with a response. */
85
+ type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
79
86
  /**
80
- * What the registered handlers recover into loader/action data — derived from the
81
- * handlers' returns, but only for handlers whose error can actually occur in `E`:
87
+ * The inferred map of handler RETURN types, keyed by domain-error tag. Making the
88
+ * factory generic over *this* (rather than the whole handler map) is what lets
89
+ * `errorHandlers` contextually type each handler's `error` param from its key —
90
+ * no annotation needed — while still capturing precise return types for recovery.
91
+ */
92
+ type HandlerReturns<DomainError extends Tagged> = Partial<Record<DomainError["_tag"], HandlerReturn>>;
93
+ /**
94
+ * The `errorHandlers` field shape for a given return map: one entry per registered
95
+ * tag, whose `error` is the matching domain error (so it autocompletes and types
96
+ * itself) and whose return is the precise, inferred `Returns[Tag]`.
97
+ */
98
+ type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Returns]: (error: Extract<DomainError, {
99
+ readonly _tag: Tag;
100
+ }>) => Returns[Tag] };
101
+ /**
102
+ * What the registered handlers recover into loader/action data — from each
103
+ * handler's return type, but only for handlers whose error can actually occur in
104
+ * `E`:
82
105
  * - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
83
106
  * - an `Effect.succeed(value)` recovers as `value`;
84
107
  * - throwables, redirects and `Effect.fail(...)` contribute nothing.
85
108
  */
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];
109
+ type RecoverOf<DomainError extends Tagged, Returns, E> = { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
110
+ readonly _tag: Tag;
111
+ }>>] extends [never] ? never : (Returns[Tag] extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never) }[keyof Returns];
87
112
  /**
88
113
  * Errors the library deals with on the loader's behalf — so the loader needn't
89
114
  * handle them itself:
@@ -99,21 +124,6 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
99
124
  * errors. The loader/action must handle these itself (catch or map them).
100
125
  */
101
126
  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;
117
127
  /**
118
128
  * A React Router context key holding a per-request effect `Context.Context`. Set
119
129
  * it in middleware and pass it to the factory's `requestContext` — the runner
@@ -146,23 +156,29 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
146
156
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
147
157
  *
148
158
  * Declare the app's **domain errors** as the type argument, then register an
149
- * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
150
- * param**). It's curried so you can pin the domain errors while the handler types
151
- * are still inferred:
159
+ * *optional* handler per domain error in `errorHandlers`. The handler keys
160
+ * autocomplete to your domain-error tags and each `error` parameter is typed from
161
+ * its key — no annotation needed. It's curried so you can pin the domain errors
162
+ * while the handler return types are still inferred:
152
163
  *
153
164
  * ```ts
154
165
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
155
166
  *
156
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
157
- * errorHandlers: {
158
- * // throw → error boundary
159
- * MyDomainError: (error: MyDomainError) =>
160
- * Effect.fail(new Response(error.message, { status: 400 })),
161
- * // DbError has no handler → falls through to the 500 default.
162
- * },
163
- * });
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
+ * }));
164
175
  * ```
165
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
+ *
166
182
  * A handler *remaps* the error by returning either a library route error
167
183
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
168
184
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -179,10 +195,10 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
179
195
  * services directly — no per-call `Effect.provide`:
180
196
  *
181
197
  * ```ts
182
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
198
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
183
199
  * runtime: getAppRuntime(), // provides Database, MyService, ...
184
200
  * errorHandlers: { ... },
185
- * });
201
+ * }));
186
202
  *
187
203
  * // `MyService` is satisfied by the runtime, not provided here:
188
204
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -193,13 +209,15 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
193
209
  * );
194
210
  * ```
195
211
  */
196
- declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers = {}, 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) => {
197
213
  /**
198
- * An optional handler per declared domain error. Omit entirely to register
199
- * none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
200
- * when there are no domain errors at all).
214
+ * An optional handler per declared domain error, keyed by its tag. The keys
215
+ * autocomplete to your domain-error tags and each handler's `error` parameter
216
+ * is typed automatically — no annotation needed. A handler *remaps* the error
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.
201
219
  */
202
- errorHandlers?: Handlers;
220
+ errorHandlers?: ErrorHandlers<DomainError, Returns>;
203
221
  /**
204
222
  * The app runtime that provides services to loader/action effects. When
205
223
  * set, effects may require its services (`RServices`, inferred from here)
@@ -208,16 +226,29 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
208
226
  */
209
227
  runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
210
228
  /**
211
- * A React Router context key (see {@link createRequestContext}) holding a
229
+ * A React Router context key (a {@link RequestContextKey}) holding a
212
230
  * per-request effect `Context.Context`. Middleware sets it for each request;
213
231
  * the runner reads `args.context.get(requestContext)` and provides those
214
232
  * services to the effect. Loader/action effects may then require
215
233
  * `ReqServices` (inferred from here) in addition to the runtime's services.
216
234
  */
217
235
  requestContext?: RequestContextKey<ReqServices>;
218
- }, ..._validate: HandlersAreValid<Handlers, DomainError> extends true ? [] : [eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>]) => {
219
- 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<Handlers, E> | DirectRecover<E>>;
220
- 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<Handlers, E> | DirectRecover<E>>;
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;
244
+ }) => {
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>>;
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;
221
252
  };
222
253
  //#endregion
223
- 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 = {
@@ -52,23 +55,29 @@ const processRouteError = (e) => {
52
55
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
53
56
  *
54
57
  * 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:
58
+ * *optional* handler per domain error in `errorHandlers`. The handler keys
59
+ * autocomplete to your domain-error tags and each `error` parameter is typed from
60
+ * its key — no annotation needed. It's curried so you can pin the domain errors
61
+ * while the handler return types are still inferred:
58
62
  *
59
63
  * ```ts
60
64
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
61
65
  *
62
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
63
- * errorHandlers: {
64
- * // throw → error boundary
65
- * MyDomainError: (error: MyDomainError) =>
66
- * 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, ..._validate) {
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.4.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
  // ---------------------------------------------------------------------------
@@ -53,22 +57,43 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
53
57
  ? never
54
58
  : DataWithResponseInit<ReturnableBodyOf<E>>;
55
59
 
60
+ /** What a handler may return: a library route error or an `Effect` failing with a response. */
61
+ type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
62
+
63
+ /**
64
+ * The inferred map of handler RETURN types, keyed by domain-error tag. Making the
65
+ * factory generic over *this* (rather than the whole handler map) is what lets
66
+ * `errorHandlers` contextually type each handler's `error` param from its key —
67
+ * no annotation needed — while still capturing precise return types for recovery.
68
+ */
69
+ type HandlerReturns<DomainError extends Tagged> = Partial<
70
+ Record<DomainError["_tag"], HandlerReturn>
71
+ >;
72
+
56
73
  /**
57
- * What the registered handlers recover into loader/action data — derived from the
58
- * handlers' returns, but only for handlers whose error can actually occur in `E`:
74
+ * The `errorHandlers` field shape for a given return map: one entry per registered
75
+ * tag, whose `error` is the matching domain error (so it autocompletes and types
76
+ * itself) and whose return is the precise, inferred `Returns[Tag]`.
77
+ */
78
+ type ErrorHandlers<DomainError extends Tagged, Returns> = {
79
+ [Tag in keyof Returns]: (error: Extract<DomainError, { readonly _tag: Tag }>) => Returns[Tag];
80
+ };
81
+
82
+ /**
83
+ * What the registered handlers recover into loader/action data — from each
84
+ * handler's return type, but only for handlers whose error can actually occur in
85
+ * `E`:
59
86
  * - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
60
87
  * - an `Effect.succeed(value)` recovers as `value`;
61
88
  * - throwables, redirects and `Effect.fail(...)` contribute nothing.
62
89
  */
63
- type RecoverOf<Handlers, E> = {
64
- [Tag in keyof Handlers]: Handlers[Tag] extends (error: infer Err) => infer R
65
- ? [Extract<E, Err>] extends [never]
66
- ? never
67
- :
68
- | (R extends ReturnableDataError<infer B> ? DataWithResponseInit<B> : never)
69
- | (R extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never)
70
- : never;
71
- }[keyof Handlers];
90
+ type RecoverOf<DomainError extends Tagged, Returns, E> = {
91
+ [Tag in keyof Returns]: [Extract<E, Extract<DomainError, { readonly _tag: Tag }>>] extends [never]
92
+ ? never
93
+ :
94
+ | (Returns[Tag] extends ReturnableDataError<infer B> ? DataWithResponseInit<B> : never)
95
+ | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never);
96
+ }[keyof Returns];
72
97
 
73
98
  /**
74
99
  * Errors the library deals with on the loader's behalf — so the loader needn't
@@ -87,31 +112,6 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
87
112
  */
88
113
  type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
89
114
 
90
- /**
91
- * The shape a handler map must satisfy: an OPTIONAL handler per declared domain
92
- * error, keyed by its tag, taking that error and returning a library route error
93
- * or an `Effect` failing with a response. Used only as a validation constraint —
94
- * the concrete handler types stay precise via the `const Handlers` inference.
95
- */
96
- type ValidHandlers<DomainError extends Tagged> = {
97
- [Tag in DomainError["_tag"]]?: (
98
- error: Extract<DomainError, { readonly _tag: Tag }>,
99
- ) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
100
- };
101
-
102
- /**
103
- * True when every registered handler is keyed by a declared domain error's tag and
104
- * returns a library route error or a failing `Effect`. A handler for an unknown tag
105
- * (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
106
- */
107
- type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [
108
- DomainError["_tag"],
109
- ]
110
- ? Handlers extends ValidHandlers<DomainError>
111
- ? true
112
- : false
113
- : false;
114
-
115
115
  // ---------------------------------------------------------------------------
116
116
  // Internal runtime helpers.
117
117
  // ---------------------------------------------------------------------------
@@ -169,23 +169,29 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
169
169
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
170
170
  *
171
171
  * Declare the app's **domain errors** as the type argument, then register an
172
- * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
173
- * param**). It's curried so you can pin the domain errors while the handler types
174
- * are still inferred:
172
+ * *optional* handler per domain error in `errorHandlers`. The handler keys
173
+ * autocomplete to your domain-error tags and each `error` parameter is typed from
174
+ * its key — no annotation needed. It's curried so you can pin the domain errors
175
+ * while the handler return types are still inferred:
175
176
  *
176
177
  * ```ts
177
178
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
178
179
  *
179
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
180
- * errorHandlers: {
181
- * // throw → error boundary
182
- * MyDomainError: (error: MyDomainError) =>
183
- * Effect.fail(new Response(error.message, { status: 400 })),
184
- * // DbError has no handler → falls through to the 500 default.
185
- * },
186
- * });
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
+ * }));
187
188
  * ```
188
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
+ *
189
195
  * A handler *remaps* the error by returning either a library route error
190
196
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
191
197
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -202,10 +208,10 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
202
208
  * services directly — no per-call `Effect.provide`:
203
209
  *
204
210
  * ```ts
205
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
211
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
206
212
  * runtime: getAppRuntime(), // provides Database, MyService, ...
207
213
  * errorHandlers: { ... },
208
- * });
214
+ * }));
209
215
  *
210
216
  * // `MyService` is satisfied by the runtime, not provided here:
211
217
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -217,14 +223,26 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
217
223
  * ```
218
224
  */
219
225
  export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
220
- return function defineErrorHandlers<const Handlers = {}, RServices = never, ReqServices = never>(
221
- config: {
226
+ return function defineErrorHandlers<
227
+ const Returns extends HandlerReturns<DomainError> = {},
228
+ RServices = never,
229
+ ReqServices = never,
230
+ const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {},
231
+ >(
232
+ /**
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.
236
+ */
237
+ builder: (respond: BaseRespond) => {
222
238
  /**
223
- * An optional handler per declared domain error. Omit entirely to register
224
- * none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
225
- * when there are no domain errors at all).
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.
226
244
  */
227
- errorHandlers?: Handlers;
245
+ errorHandlers?: ErrorHandlers<DomainError, Returns>;
228
246
  /**
229
247
  * The app runtime that provides services to loader/action effects. When
230
248
  * set, effects may require its services (`RServices`, inferred from here)
@@ -233,25 +251,29 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
233
251
  */
234
252
  runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
235
253
  /**
236
- * A React Router context key (see {@link createRequestContext}) holding a
254
+ * A React Router context key (a {@link RequestContextKey}) holding a
237
255
  * per-request effect `Context.Context`. Middleware sets it for each request;
238
256
  * the runner reads `args.context.get(requestContext)` and provides those
239
257
  * services to the effect. Loader/action effects may then require
240
258
  * `ReqServices` (inferred from here) in addition to the runtime's services.
241
259
  */
242
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;
243
269
  },
244
- // Validation: a handler for a non-domain error, or one that doesn't return a
245
- // route error / failing `Effect`, makes this rest parameter required and forces
246
- // a compile error at the call.
247
- ..._validate: HandlersAreValid<Handlers, DomainError> extends true
248
- ? []
249
- : [
250
- eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
251
- ]
252
270
  ) {
271
+ const config = builder(baseRespond);
253
272
  const runtime = config.runtime;
254
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;
255
277
 
256
278
  // Uniform call signature for dispatch (the per-tag handler types are narrower).
257
279
  // Defaults to an empty map when `errorHandlers` is omitted.
@@ -281,7 +303,7 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
281
303
  DomainError
282
304
  >,
283
305
  ]
284
- ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
306
+ ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
285
307
  return (args: Args) => {
286
308
  // The internal channel is deliberately loose (`unknown` success); the outer
287
309
  // cast restores the precise resolved type (computed from `E` and the handler
@@ -328,13 +350,14 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
328
350
  const result = runtime
329
351
  ? runtime.runPromise(provided)
330
352
  : Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
331
- return result as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
353
+ return result as Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
332
354
  };
333
355
  }
334
356
 
335
357
  return {
336
358
  makeLoader: makeLoaderOrAction,
337
359
  makeAction: makeLoaderOrAction,
360
+ Respond,
338
361
  };
339
362
  };
340
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,