react-router-effect 0.5.0 → 0.6.1

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
@@ -54,8 +54,10 @@ A **declared domain error** may be left unhandled:
54
54
 
55
55
  **Any other error** a route consumes — a service-specific error that isn't a declared domain
56
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.
57
+ fails to type-check with a message naming the unhandled error(s). This gives app-wide defaults for
58
+ declared errors while enforcing explicit handling of feature/service-specific ones. (Likewise, an
59
+ effect that requires a service neither the `runtime` nor the `requestContext` provides fails to
60
+ type-check with a message naming the missing service.)
59
61
 
60
62
  ## Usage
61
63
 
@@ -64,7 +66,7 @@ handling of feature/service-specific ones.
64
66
  ```ts
65
67
  // app/route.server.ts
66
68
  import { Data, Effect } from "effect";
67
- import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-effect";
69
+ import { makeLoaderOrActionFactory } from "react-router-effect";
68
70
 
69
71
  class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
70
72
  class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
@@ -74,27 +76,31 @@ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
74
76
  // so it falls through to the 500 default.
75
77
  type DomainErrors = FormError | BadInputError | DbError;
76
78
 
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
- };
79
+ // Curried: pin the domain errors, then the handler types are inferred. The config
80
+ // is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
81
+ // so your handlers can recover/throw, and the factory hands back a single `Respond`
82
+ // for your routes to import (no auto-import ambiguity with a library export).
83
+ export const { makeLoader, makeAction, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
84
+ (Respond) => ({
85
+ // App-specific helpers — merged onto the returned `Respond` (base helpers win):
86
+ respond: {
87
+ formError: (reply: SubmissionResponse) => new FormError({ reply }),
88
+ },
89
+ errorHandlers: {
90
+ // keys autocomplete to your domain-error tags; `error` is typed from its key.
91
+ // recover: short-circuit and hand the reply to the component
92
+ FormError: (error) => Respond.early({ reply: error.reply }),
93
+ // throw: send to the error boundary
94
+ BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
95
+ },
96
+ }),
97
+ );
93
98
  ```
94
99
 
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.
100
+ > **One `Respond`, no annotations.** Your routes import the `Respond` returned here — there's
101
+ > no library-level `Respond` to clash with it on auto-import. Handler keys autocomplete to your
102
+ > declared domain-error tags, each `error` parameter is typed from its key, and the precise
103
+ > recover types are derived from each handler's return.
98
104
 
99
105
  ### 2. Write loaders/actions as effects
100
106
 
@@ -153,10 +159,10 @@ import { ManagedRuntime } from "effect";
153
159
  export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
154
160
 
155
161
  // app/route.server.ts
156
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
162
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
157
163
  runtime: appRuntime,
158
164
  errorHandlers: { ... },
159
- });
165
+ }));
160
166
 
161
167
  // app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
162
168
  const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -174,8 +180,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
174
180
  `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
175
181
 
176
182
  ```ts
177
- makeLoaderOrActionFactory()({ runtime });
178
- makeLoaderOrActionFactory()({});
183
+ makeLoaderOrActionFactory()(() => ({ runtime }));
184
+ makeLoaderOrActionFactory()(() => ({}));
179
185
  ```
180
186
 
181
187
  ### Per-request services from middleware
@@ -210,10 +216,10 @@ export const middleware: Route.MiddlewareFunction[] = [
210
216
  ];
211
217
 
212
218
  // app/route.server.ts — wire the same key into the factory:
213
- export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
219
+ export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
214
220
  runtime: appRuntime,
215
221
  requestContext,
216
- });
222
+ }));
217
223
 
218
224
  // the loader requires RequestContext directly — no provide, fresh each request:
219
225
  export const loader = makeLoader(() =>
@@ -230,13 +236,16 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
230
236
 
231
237
  ## API
232
238
 
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.
239
+ - **`makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
240
+ → `{ makeLoader, makeAction, Respond }` (the two makers are the same wrapper). The config is a
241
+ _builder_ that receives the base `Respond`; all its fields are optional. The returned `Respond` is
242
+ the base helpers merged with your `respond` extensions (base helpers win). A non-domain error left
243
+ in a loader/action's error channel — or a required service that neither the `runtime` nor the
244
+ `requestContext` provides — is a compile error.
237
245
  - **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
238
246
  effect context (`RouterContext<Context.Context<ReqServices>>`).
239
- - **`Respond`** — `early` (recover), `throw`, `redirect`.
247
+ - **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
248
+ `respond` helpers you add.
240
249
  - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
241
250
  route errors, and **`isRouteError`** to narrow them.
242
251
  - **`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
  };
@@ -119,6 +124,35 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
119
124
  * errors. The loader/action must handle these itself (catch or map them).
120
125
  */
121
126
  type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
127
+ /**
128
+ * A readable compile-time diagnostic. It's intersected onto the *effect return type*
129
+ * of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
130
+ * reports this message, and the offending types, *at the `makeLoader`/`makeAction`
131
+ * call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
132
+ * intersection) when the effect is fully handled and all its requirements are provided:
133
+ *
134
+ * - **Unhandled error** — the effect can fail with something that isn't a declared
135
+ * domain error, a library route error, or respondable; `unhandledErrors` names them.
136
+ * - **Missing requirement** — the effect requires a service that neither the
137
+ * `runtime` nor the `requestContext` provides; `missingRequirements` names them.
138
+ *
139
+ * The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
140
+ * its text verbatim rather than collapsing it to an alias name.
141
+ */
142
+ /**
143
+ * Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
144
+ * (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
145
+ */
146
+ type NameError<U> = U extends {
147
+ readonly _tag: infer Tag extends string;
148
+ } ? Tag : U;
149
+ type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends [never] ? [Exclude<R, Provided>] extends [never] ? unknown : {
150
+ "react-router-effect": "This loader/action requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
151
+ missingRequirements: Exclude<R, Provided>;
152
+ } : {
153
+ "react-router-effect": "This loader/action can fail with an error the library does not handle. Catch it in the effect (e.g. Effect.catchTag), make it Respondable, or add it to your DomainError union.";
154
+ unhandledErrors: NameError<Unhandled<E, DomainError>>;
155
+ };
122
156
  /**
123
157
  * A React Router context key holding a per-request effect `Context.Context`. Set
124
158
  * it in middleware and pass it to the factory's `requestContext` — the runner
@@ -159,15 +193,21 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
159
193
  * ```ts
160
194
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
161
195
  *
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
- * });
196
+ * export const { makeLoader, makeAction, Respond } =
197
+ * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
198
+ * errorHandlers: {
199
+ * // `error` is typed as MyDomainError automatically. throw → error boundary
200
+ * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
201
+ * // DbError has no handler → falls through to the 500 default.
202
+ * },
203
+ * }));
169
204
  * ```
170
205
  *
206
+ * The inner config is a **builder** — it receives the library's `Respond` helpers so
207
+ * your handlers can recover/throw with them, and returns the config. `Respond` is
208
+ * also returned from the factory (extended with any `respond` helpers you add), so
209
+ * your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
210
+ *
171
211
  * A handler *remaps* the error by returning either a library route error
172
212
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
173
213
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -184,10 +224,10 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
184
224
  * services directly — no per-call `Effect.provide`:
185
225
  *
186
226
  * ```ts
187
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
227
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
188
228
  * runtime: getAppRuntime(), // provides Database, MyService, ...
189
229
  * errorHandlers: { ... },
190
- * });
230
+ * }));
191
231
  *
192
232
  * // `MyService` is satisfied by the runtime, not provided here:
193
233
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -198,13 +238,13 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
198
238
  * );
199
239
  * ```
200
240
  */
201
- declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never>(config: {
241
+ 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
242
  /**
203
243
  * An optional handler per declared domain error, keyed by its tag. The keys
204
244
  * autocomplete to your domain-error tags and each handler's `error` parameter
205
245
  * 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.
246
+ * by returning a library route error (the builder's `Respond.early` / `throw`
247
+ * / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
208
248
  */
209
249
  errorHandlers?: ErrorHandlers<DomainError, Returns>;
210
250
  /**
@@ -222,9 +262,22 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
222
262
  * `ReqServices` (inferred from here) in addition to the runtime's services.
223
263
  */
224
264
  requestContext?: RequestContextKey<ReqServices>;
265
+ /**
266
+ * App-specific `Respond` helpers — merged onto the base `Respond` that the
267
+ * factory returns, so your app imports one `Respond`. Typically opinionated
268
+ * error constructors; annotate their parameters, e.g.
269
+ * `{ formError: (reply: string) => new FormError({ reply }) }`. The base
270
+ * helpers (`early` / `throw` / `redirect`) always win the merge.
271
+ */
272
+ respond?: ExtraRespond;
225
273
  }) => {
226
- 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
- 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>>;
274
+ makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
275
+ makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
276
+ Respond: {
277
+ early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
278
+ throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
279
+ redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
280
+ } & ExtraRespond;
228
281
  };
229
282
  //#endregion
230
- export { type AnyRouteError, type ErrorHandler, type RequestContextKey, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
283
+ 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,14 +109,20 @@ 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
- function makeLoaderOrAction(fn, ..._handle) {
122
+ function makeLoaderOrAction(fn) {
123
+ const run = fn;
109
124
  return (args) => {
110
- const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
125
+ const program = run(args).pipe(Effect.catchIf((_e) => true, (e) => {
111
126
  if (isUserError(e)) {
112
127
  const out = userHandlers[e._tag](e);
113
128
  return isRouteError(out) ? processRouteError(out) : out;
@@ -122,9 +137,10 @@ function makeLoaderOrActionFactory() {
122
137
  }
123
138
  return {
124
139
  makeLoader: makeLoaderOrAction,
125
- makeAction: makeLoaderOrAction
140
+ makeAction: makeLoaderOrAction,
141
+ Respond: Respond$1
126
142
  };
127
143
  };
128
144
  }
129
145
  //#endregion
130
- export { Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
146
+ 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.1",
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
  // ---------------------------------------------------------------------------
@@ -108,6 +112,43 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
108
112
  */
109
113
  type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
110
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // The call-site diagnostic.
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * A readable compile-time diagnostic. It's intersected onto the *effect return type*
121
+ * of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
122
+ * reports this message, and the offending types, *at the `makeLoader`/`makeAction`
123
+ * call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
124
+ * intersection) when the effect is fully handled and all its requirements are provided:
125
+ *
126
+ * - **Unhandled error** — the effect can fail with something that isn't a declared
127
+ * domain error, a library route error, or respondable; `unhandledErrors` names them.
128
+ * - **Missing requirement** — the effect requires a service that neither the
129
+ * `runtime` nor the `requestContext` provides; `missingRequirements` names them.
130
+ *
131
+ * The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
132
+ * its text verbatim rather than collapsing it to an alias name.
133
+ */
134
+ /**
135
+ * Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
136
+ * (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
137
+ */
138
+ type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
139
+
140
+ type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends [never]
141
+ ? [Exclude<R, Provided>] extends [never]
142
+ ? unknown
143
+ : {
144
+ "react-router-effect": "This loader/action requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
145
+ missingRequirements: Exclude<R, Provided>;
146
+ }
147
+ : {
148
+ "react-router-effect": "This loader/action can fail with an error the library does not handle. Catch it in the effect (e.g. Effect.catchTag), make it Respondable, or add it to your DomainError union.";
149
+ unhandledErrors: NameError<Unhandled<E, DomainError>>;
150
+ };
151
+
111
152
  // ---------------------------------------------------------------------------
112
153
  // Internal runtime helpers.
113
154
  // ---------------------------------------------------------------------------
@@ -173,15 +214,21 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
173
214
  * ```ts
174
215
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
175
216
  *
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
- * });
217
+ * export const { makeLoader, makeAction, Respond } =
218
+ * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
219
+ * errorHandlers: {
220
+ * // `error` is typed as MyDomainError automatically. throw → error boundary
221
+ * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
222
+ * // DbError has no handler → falls through to the 500 default.
223
+ * },
224
+ * }));
183
225
  * ```
184
226
  *
227
+ * The inner config is a **builder** — it receives the library's `Respond` helpers so
228
+ * your handlers can recover/throw with them, and returns the config. `Respond` is
229
+ * also returned from the factory (extended with any `respond` helpers you add), so
230
+ * your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
231
+ *
185
232
  * A handler *remaps* the error by returning either a library route error
186
233
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
187
234
  * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
@@ -198,10 +245,10 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
198
245
  * services directly — no per-call `Effect.provide`:
199
246
  *
200
247
  * ```ts
201
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
248
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
202
249
  * runtime: getAppRuntime(), // provides Database, MyService, ...
203
250
  * errorHandlers: { ... },
204
- * });
251
+ * }));
205
252
  *
206
253
  * // `MyService` is satisfied by the runtime, not provided here:
207
254
  * const loader = makeLoader((args: Route.LoaderArgs) =>
@@ -217,33 +264,53 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
217
264
  const Returns extends HandlerReturns<DomainError> = {},
218
265
  RServices = never,
219
266
  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>;
267
+ const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {},
268
+ >(
236
269
  /**
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.
270
+ * Builds the factory config. It receives the library's base `Respond` helpers
271
+ * (`early` / `throw` / `redirect`) so your `errorHandlers` can recover/throw
272
+ * with them, and returns the config object.
242
273
  */
243
- requestContext?: RequestContextKey<ReqServices>;
244
- }) {
274
+ builder: (respond: BaseRespond) => {
275
+ /**
276
+ * An optional handler per declared domain error, keyed by its tag. The keys
277
+ * autocomplete to your domain-error tags and each handler's `error` parameter
278
+ * is typed automatically — no annotation needed. A handler *remaps* the error
279
+ * by returning a library route error (the builder's `Respond.early` / `throw`
280
+ * / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
281
+ */
282
+ errorHandlers?: ErrorHandlers<DomainError, Returns>;
283
+ /**
284
+ * The app runtime that provides services to loader/action effects. When
285
+ * set, effects may require its services (`RServices`, inferred from here)
286
+ * without providing layers, and runs go through `runtime.runPromise`. When
287
+ * omitted, `RServices` is `never` and effects must require nothing.
288
+ */
289
+ runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
290
+ /**
291
+ * A React Router context key (a {@link RequestContextKey}) holding a
292
+ * per-request effect `Context.Context`. Middleware sets it for each request;
293
+ * the runner reads `args.context.get(requestContext)` and provides those
294
+ * services to the effect. Loader/action effects may then require
295
+ * `ReqServices` (inferred from here) in addition to the runtime's services.
296
+ */
297
+ requestContext?: RequestContextKey<ReqServices>;
298
+ /**
299
+ * App-specific `Respond` helpers — merged onto the base `Respond` that the
300
+ * factory returns, so your app imports one `Respond`. Typically opinionated
301
+ * error constructors; annotate their parameters, e.g.
302
+ * `{ formError: (reply: string) => new FormError({ reply }) }`. The base
303
+ * helpers (`early` / `throw` / `redirect`) always win the merge.
304
+ */
305
+ respond?: ExtraRespond;
306
+ },
307
+ ) {
308
+ const config = builder(baseRespond);
245
309
  const runtime = config.runtime;
246
310
  const requestContextKey = config.requestContext;
311
+ // The single `Respond` an app uses: base helpers plus any app-specific ones.
312
+ // Base wins the merge so core helpers can't be shadowed.
313
+ const Respond = { ...config.respond, ...baseRespond } as BaseRespond & ExtraRespond;
247
314
 
248
315
  // Uniform call signature for dispatch (the per-tag handler types are narrower).
249
316
  // Defaults to an empty map when `errorHandlers` is omitted.
@@ -255,34 +322,31 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
255
322
  const isUserError = (e: unknown): e is DomainError =>
256
323
  typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
257
324
 
258
- function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
259
- // The effect may require `RServices` (provided by the configured `runtime`)
260
- // and `ReqServices` (provided per-request from `requestContext`). Both are
261
- // `never` when their source isn't configured, so the effect must require
262
- // nothing extra.
263
- fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>,
264
- // If the effect can still fail with something the library won't handle — a
265
- // service-specific error that isn't a declared domain error, a library route
266
- // error, or respondable — this rest parameter becomes required and the call
267
- // fails to type-check, forcing the loader/action to handle it.
268
- ..._handle: [Unhandled<E, DomainError>] extends [never]
269
- ? []
270
- : [
271
- theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
272
- E,
273
- DomainError
274
- >,
275
- ]
325
+ function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(
326
+ // `A`/`E`/`R` infer directly from the effect (naked, so inference stays robust).
327
+ // The diagnostic is intersected onto the effect's return type: when the effect
328
+ // requires a service the factory doesn't provide (`R` ⊄ runtime+requestContext),
329
+ // or can fail with something the library won't handle, `Diagnose` becomes a
330
+ // message object the effect isn't assignable to — so this argument fails to
331
+ // type-check with a readable explanation, and the offending types, right here.
332
+ fn: (
333
+ args: Args,
334
+ ) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>,
276
335
  ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
336
+ // The diagnostic is irrelevant inside the body (it's `unknown` for any valid
337
+ // call); treat `fn` as the plain effect-returning function it is.
338
+ const run = fn as unknown as (
339
+ args: Args,
340
+ ) => Effect.Effect<unknown, E, RServices | ReqServices>;
277
341
  return (args: Args) => {
278
342
  // The internal channel is deliberately loose (`unknown` success); the outer
279
343
  // cast restores the precise resolved type (computed from `E` and the handler
280
344
  // map). Sound at runtime — the values produced are exactly those types.
281
- const program = fn(args).pipe(
345
+ const program = run(args).pipe(
282
346
  // Catch the whole error channel and dispatch. The refinement is `e is E`
283
347
  // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
284
- // overload crashes tsc over a generic `E`). A declared domain error with
285
- // no handler (and not respondable) falls through to the 500 default.
348
+ // overload crashes tsc over a generic `E`). A declared domain error with no
349
+ // handler (and not respondable) falls through to the 500 default.
286
350
  Effect.catchIf(
287
351
  (_e): _e is E => true,
288
352
  (e): Effect.Effect<unknown, FailureResponse> => {
@@ -327,6 +391,7 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
327
391
  return {
328
392
  makeLoader: makeLoaderOrAction,
329
393
  makeAction: makeLoaderOrAction,
394
+ Respond,
330
395
  };
331
396
  };
332
397
  }
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,