react-router-effect 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,18 +39,23 @@ A loader/action effect can short-circuit in three ways, via the `Respond` helper
39
39
  | `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
40
40
  | `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
41
41
 
42
- Your own **domain errors** are mapped to one of those outcomes by handlers you register with
43
- the factory. A handler _remaps_ an error by returning either:
42
+ You declare your app's **domain errors** as a type argument to the factory, and may register a
43
+ handler per domain error. A handler _remaps_ an error by returning either:
44
44
 
45
45
  - a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
46
46
  - an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
47
47
  to **throw** a `Response` / `DataWithResponseInit`.
48
48
 
49
- Handling is **optional**:
49
+ A **declared domain error** may be left unhandled:
50
50
 
51
- - An **unregistered** error that implements [`HttpServerRespondable`](https://effect.website)
52
- is rendered automatically from its own response.
53
- - Any other unregistered error falls through to a **500**.
51
+ - if it implements [`HttpServerRespondable`](https://effect.website) it's rendered automatically
52
+ from its own response;
53
+ - otherwise it falls through to a **500**.
54
+
55
+ **Any other error** a route consumes — a service-specific error that isn't a declared domain
56
+ error — **must be handled** in the loader/action (caught or mapped), or `makeLoader`/`makeAction`
57
+ fails to type-check. This gives app-wide defaults for declared errors while enforcing explicit
58
+ handling of feature/service-specific ones.
54
59
 
55
60
  ## Usage
56
61
 
@@ -63,8 +68,14 @@ import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-
63
68
 
64
69
  class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
65
70
  class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
71
+ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
72
+
73
+ // Declare every error your app handles app-wide. `DbError` has no handler below,
74
+ // so it falls through to the 500 default.
75
+ type DomainErrors = FormError | BadInputError | DbError;
66
76
 
67
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory({
77
+ // Curried: pin the domain errors, then the handler types are inferred.
78
+ export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
68
79
  errorHandlers: {
69
80
  // recover: short-circuit and hand the reply to the component
70
81
  FormError: (error: FormError) => baseRespond.early({ reply: error.reply }),
@@ -81,8 +92,8 @@ export const Respond = {
81
92
  };
82
93
  ```
83
94
 
84
- > **Annotate each handler's parameter.** The registered error set and the precise recover
85
- > types are derived from the handler map's parameter and return types.
95
+ > **Annotate each handler's parameter.** Handlers must be keyed by a declared domain error, and
96
+ > the precise recover types are derived from the handler map's parameter and return types.
86
97
 
87
98
  ### 2. Write loaders/actions as effects
88
99
 
@@ -131,8 +142,9 @@ A registered handler, if present, still takes precedence over the error's own re
131
142
 
132
143
  ## API
133
144
 
134
- - **`makeLoaderOrActionFactory({ errorHandlers })`** → `{ makeLoader, makeAction, makeLoaderOrAction }`
135
- (the three are the same wrapper under different names).
145
+ - **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers })`** → `{ makeLoader, makeAction }`
146
+ (both are the same wrapper). A non-domain error left in a loader/action's error channel is a
147
+ compile error.
136
148
  - **`Respond`** — `early` (recover), `throw`, `redirect`.
137
149
  - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
138
150
  route errors, and **`isRouteError`** to narrow them.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { HttpServerRespondable } from "effect/unstable/http";
2
3
  import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
3
4
 
4
5
  //#region src/errors.d.ts
@@ -49,6 +50,9 @@ declare const Respond: {
49
50
  declare const isRouteError: (value: unknown) => value is AnyRouteError;
50
51
  //#endregion
51
52
  //#region src/factory.d.ts
53
+ type Tagged = {
54
+ readonly _tag: string;
55
+ };
52
56
  /**
53
57
  * Everything a failure branch rejects with. React Router routes a thrown
54
58
  * `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
@@ -80,41 +84,73 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_D
80
84
  * - throwables, redirects and `Effect.fail(...)` contribute nothing.
81
85
  */
82
86
  type RecoverOf<Handlers, E> = { [Tag in keyof Handlers]: Handlers[Tag] extends ((error: infer Err) => infer R) ? [Extract<E, Err>] extends [never] ? never : (R extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (R extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never) : never }[keyof Handlers];
83
- /** Shape every handler must satisfy: return a library route error or an `Effect` failing with a response. */
84
- type ValidHandlers = Record<string, (error: never) => AnyRouteError | Effect.Effect<unknown, FailureResponse>>;
87
+ /**
88
+ * Errors the library deals with on the loader's behalf — so the loader needn't
89
+ * handle them itself:
90
+ * - the app's **declared domain errors** (`DomainError`) — handled by a registered
91
+ * handler, or left to the 500 / auto-respond default;
92
+ * - **library route errors** raised directly via `Respond`;
93
+ * - anything that renders itself via **`HttpServerRespondable`**.
94
+ */
95
+ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
96
+ /**
97
+ * What remains in a loader/action's error channel that the library will NOT handle
98
+ * — i.e. service-specific errors the route consumes that aren't declared domain
99
+ * errors. The loader/action must handle these itself (catch or map them).
100
+ */
101
+ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
102
+ /**
103
+ * The shape a handler map must satisfy: an OPTIONAL handler per declared domain
104
+ * error, keyed by its tag, taking that error and returning a library route error
105
+ * or an `Effect` failing with a response. Used only as a validation constraint —
106
+ * the concrete handler types stay precise via the `const Handlers` inference.
107
+ */
108
+ type ValidHandlers<DomainError extends Tagged> = { [Tag in DomainError["_tag"]]?: (error: Extract<DomainError, {
109
+ readonly _tag: Tag;
110
+ }>) => AnyRouteError | Effect.Effect<unknown, FailureResponse> };
111
+ /**
112
+ * True when every registered handler is keyed by a declared domain error's tag and
113
+ * returns a library route error or a failing `Effect`. A handler for an unknown tag
114
+ * (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
115
+ */
116
+ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [DomainError["_tag"]] ? Handlers extends ValidHandlers<DomainError> ? true : false : false;
85
117
  /**
86
118
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
87
119
  *
88
- * Register a handler per domain error in `errorHandlers` (**annotate each handler's
89
- * param** the registered set and recover types are derived from the map). A
90
- * handler *remaps* the error by returning either a library route error
91
- * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
92
- * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
93
- * throw). Recovered values become loader/action data, typed precisely (non-generic).
120
+ * Declare the app's **domain errors** as the type argument, then register an
121
+ * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
122
+ * param**). It's curried so you can pin the domain errors while the handler types
123
+ * are still inferred:
94
124
  *
95
- * Handling is optional: an *unregistered* error is allowed. If it implements
96
- * `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
97
- * error boundary as a 500.
98
- *
99
- * @example
100
125
  * ```ts
101
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
126
+ * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
127
+ *
128
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
102
129
  * errorHandlers: {
103
130
  * // throw → error boundary
104
131
  * MyDomainError: (error: MyDomainError) =>
105
132
  * Effect.fail(new Response(error.message, { status: 400 })),
106
- * // remaprecover via a library returnable
107
- * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
133
+ * // DbError has no handler falls through to the 500 default.
108
134
  * },
109
135
  * });
110
136
  * ```
137
+ *
138
+ * A handler *remaps* the error by returning either a library route error
139
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
140
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
141
+ * throw). Recovered values become loader/action data, typed precisely.
142
+ *
143
+ * **Declared domain errors** may be left unhandled: if one implements
144
+ * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
145
+ * to a 500. **Any other error** — a service-specific error the route consumes that
146
+ * isn't a declared domain error — *must* be handled in the loader/action, or
147
+ * `makeLoader`/`makeAction` fails to type-check.
111
148
  */
112
- declare function makeLoaderOrActionFactory<const Handlers>(config: {
149
+ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers>(config: {
113
150
  errorHandlers: Handlers;
114
- }, ..._validate: Handlers extends ValidHandlers ? [] : [eachHandlerMustReturnARouteErrorOrAnEffectFailingWithAResponse: ValidHandlers]): {
115
- makeLoaderOrAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
116
- makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
117
- makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
151
+ }, ..._validate: HandlersAreValid<Handlers, DomainError> extends true ? [] : [eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>]) => {
152
+ makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
153
+ makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
118
154
  };
119
155
  //#endregion
120
156
  export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
package/dist/index.mjs CHANGED
@@ -51,48 +51,54 @@ const processRouteError = (e) => {
51
51
  /**
52
52
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
53
53
  *
54
- * Register a handler per domain error in `errorHandlers` (**annotate each handler's
55
- * param** the registered set and recover types are derived from the map). A
56
- * handler *remaps* the error by returning either a library route error
57
- * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
58
- * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
59
- * throw). Recovered values become loader/action data, typed precisely (non-generic).
54
+ * Declare the app's **domain errors** as the type argument, then register an
55
+ * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
56
+ * param**). It's curried so you can pin the domain errors while the handler types
57
+ * are still inferred:
60
58
  *
61
- * Handling is optional: an *unregistered* error is allowed. If it implements
62
- * `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
63
- * error boundary as a 500.
64
- *
65
- * @example
66
59
  * ```ts
67
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
60
+ * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
61
+ *
62
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
68
63
  * errorHandlers: {
69
64
  * // throw → error boundary
70
65
  * MyDomainError: (error: MyDomainError) =>
71
66
  * Effect.fail(new Response(error.message, { status: 400 })),
72
- * // remaprecover via a library returnable
73
- * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
67
+ * // DbError has no handler falls through to the 500 default.
74
68
  * },
75
69
  * });
76
70
  * ```
71
+ *
72
+ * A handler *remaps* the error by returning either a library route error
73
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
74
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
75
+ * throw). Recovered values become loader/action data, typed precisely.
76
+ *
77
+ * **Declared domain errors** may be left unhandled: if one implements
78
+ * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
79
+ * to a 500. **Any other error** — a service-specific error the route consumes that
80
+ * isn't a declared domain error — *must* be handled in the loader/action, or
81
+ * `makeLoader`/`makeAction` fails to type-check.
77
82
  */
78
- function makeLoaderOrActionFactory(config, ..._validate) {
79
- const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in config.errorHandlers;
80
- const userHandlers = config.errorHandlers;
81
- function makeLoaderOrAction(fn) {
82
- return (args) => Effect.runPromise(fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
83
- if (isUserError(e)) {
84
- const out = userHandlers[e._tag](e);
85
- return isRouteError(out) ? processRouteError(out) : out;
86
- }
87
- if (isRouteError(e)) return processRouteError(e);
88
- if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
89
- return internalServerError();
90
- })));
91
- }
92
- return {
93
- makeLoaderOrAction,
94
- makeLoader: makeLoaderOrAction,
95
- makeAction: makeLoaderOrAction
83
+ function makeLoaderOrActionFactory() {
84
+ return function defineErrorHandlers(config, ..._validate) {
85
+ const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in config.errorHandlers;
86
+ const userHandlers = config.errorHandlers;
87
+ function makeLoaderOrAction(fn, ..._handle) {
88
+ return (args) => Effect.runPromise(fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
89
+ if (isUserError(e)) {
90
+ const out = userHandlers[e._tag](e);
91
+ return isRouteError(out) ? processRouteError(out) : out;
92
+ }
93
+ if (isRouteError(e)) return processRouteError(e);
94
+ if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
95
+ return internalServerError();
96
+ })));
97
+ }
98
+ return {
99
+ makeLoader: makeLoaderOrAction,
100
+ makeAction: makeLoaderOrAction
101
+ };
96
102
  };
97
103
  }
98
104
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schemastore.org/package.json",
3
3
  "name": "react-router-effect",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
6
6
  "keywords": [
7
7
  "action",
@@ -62,7 +62,7 @@
62
62
  "packageManager": {
63
63
  "name": "pnpm",
64
64
  "version": "11.9.0",
65
- "onFail": "download"
65
+ "onFail": "warn"
66
66
  }
67
67
  },
68
68
  "scripts": {
package/src/factory.ts CHANGED
@@ -52,11 +52,6 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
52
52
  ? never
53
53
  : DataWithResponseInit<ReturnableBodyOf<E>>;
54
54
 
55
- /** The domain errors a handler map registers — derived from the handlers' params. */
56
- type RegisteredError<Handlers> = {
57
- [Tag in keyof Handlers]: Handlers[Tag] extends (error: infer Err) => unknown ? Err : never;
58
- }[keyof Handlers];
59
-
60
55
  /**
61
56
  * What the registered handlers recover into loader/action data — derived from the
62
57
  * handlers' returns, but only for handlers whose error can actually occur in `E`:
@@ -74,11 +69,47 @@ type RecoverOf<Handlers, E> = {
74
69
  : never;
75
70
  }[keyof Handlers];
76
71
 
77
- /** Shape every handler must satisfy: return a library route error or an `Effect` failing with a response. */
78
- type ValidHandlers = Record<
79
- string,
80
- (error: never) => AnyRouteError | Effect.Effect<unknown, FailureResponse>
81
- >;
72
+ /**
73
+ * Errors the library deals with on the loader's behalf — so the loader needn't
74
+ * handle them itself:
75
+ * - the app's **declared domain errors** (`DomainError`) handled by a registered
76
+ * handler, or left to the 500 / auto-respond default;
77
+ * - **library route errors** raised directly via `Respond`;
78
+ * - anything that renders itself via **`HttpServerRespondable`**.
79
+ */
80
+ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
81
+
82
+ /**
83
+ * What remains in a loader/action's error channel that the library will NOT handle
84
+ * — i.e. service-specific errors the route consumes that aren't declared domain
85
+ * errors. The loader/action must handle these itself (catch or map them).
86
+ */
87
+ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
88
+
89
+ /**
90
+ * The shape a handler map must satisfy: an OPTIONAL handler per declared domain
91
+ * error, keyed by its tag, taking that error and returning a library route error
92
+ * or an `Effect` failing with a response. Used only as a validation constraint —
93
+ * the concrete handler types stay precise via the `const Handlers` inference.
94
+ */
95
+ type ValidHandlers<DomainError extends Tagged> = {
96
+ [Tag in DomainError["_tag"]]?: (
97
+ error: Extract<DomainError, { readonly _tag: Tag }>,
98
+ ) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
99
+ };
100
+
101
+ /**
102
+ * True when every registered handler is keyed by a declared domain error's tag and
103
+ * returns a library route error or a failing `Effect`. A handler for an unknown tag
104
+ * (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
105
+ */
106
+ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [
107
+ DomainError["_tag"],
108
+ ]
109
+ ? Handlers extends ValidHandlers<DomainError>
110
+ ? true
111
+ : false
112
+ : false;
82
113
 
83
114
  // ---------------------------------------------------------------------------
84
115
  // Internal runtime helpers.
@@ -103,95 +134,113 @@ const processRouteError = (
103
134
  /**
104
135
  * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
105
136
  *
106
- * Register a handler per domain error in `errorHandlers` (**annotate each handler's
107
- * param** the registered set and recover types are derived from the map). A
108
- * handler *remaps* the error by returning either a library route error
109
- * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
110
- * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
111
- * throw). Recovered values become loader/action data, typed precisely (non-generic).
112
- *
113
- * Handling is optional: an *unregistered* error is allowed. If it implements
114
- * `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
115
- * error boundary as a 500.
137
+ * Declare the app's **domain errors** as the type argument, then register an
138
+ * *optional* handler per domain error in `errorHandlers` (**annotate each handler's
139
+ * param**). It's curried so you can pin the domain errors while the handler types
140
+ * are still inferred:
116
141
  *
117
- * @example
118
142
  * ```ts
119
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory({
143
+ * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
144
+ *
145
+ * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
120
146
  * errorHandlers: {
121
147
  * // throw → error boundary
122
148
  * MyDomainError: (error: MyDomainError) =>
123
149
  * Effect.fail(new Response(error.message, { status: 400 })),
124
- * // remaprecover via a library returnable
125
- * FormError: (error: FormError) => Respond.early({ reply: error.reply }),
150
+ * // DbError has no handler falls through to the 500 default.
126
151
  * },
127
152
  * });
128
153
  * ```
154
+ *
155
+ * A handler *remaps* the error by returning either a library route error
156
+ * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
157
+ * `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
158
+ * throw). Recovered values become loader/action data, typed precisely.
159
+ *
160
+ * **Declared domain errors** may be left unhandled: if one implements
161
+ * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
162
+ * to a 500. **Any other error** — a service-specific error the route consumes that
163
+ * isn't a declared domain error — *must* be handled in the loader/action, or
164
+ * `makeLoader`/`makeAction` fails to type-check.
129
165
  */
130
- export function makeLoaderOrActionFactory<const Handlers>(
131
- config: { errorHandlers: Handlers },
132
- // Validation: a handler that doesn't return a route error or a failing `Effect`
133
- // makes this rest parameter required, forcing a compile error at the call.
134
- ..._validate: Handlers extends ValidHandlers
135
- ? []
136
- : [eachHandlerMustReturnARouteErrorOrAnEffectFailingWithAResponse: ValidHandlers]
137
- ) {
138
- /** Domain errors this factory has handlers for. */
139
- type UserError = Extract<RegisteredError<Handlers>, Tagged>;
140
-
141
- const isUserError = (e: unknown): e is UserError =>
142
- typeof e === "object" &&
143
- e !== null &&
144
- "_tag" in e &&
145
- (e as Tagged)._tag in (config.errorHandlers as object);
146
-
147
- // Uniform call signature for dispatch (the per-tag handler types are narrower).
148
- const userHandlers = config.errorHandlers as unknown as Record<string, ErrorHandler<UserError>>;
149
-
150
- function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
151
- fn: (args: Args) => Effect.Effect<A, E>,
152
- // The resolved value: the loader's own success, the body of any `Respond.early`
153
- // raised directly, plus everything the registered (and reachable) handlers
154
- // recover with.
155
- ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
156
- return (args: Args) =>
157
- // The internal channel is deliberately loose (`unknown` success); the outer
158
- // cast restores the precise resolved type (computed from `E` and the handler
159
- // map). Sound at runtime the values produced are exactly those types.
160
- Effect.runPromise(
161
- fn(args).pipe(
162
- // Catch the whole error channel and dispatch. The refinement is `e is E`
163
- // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
164
- // overload crashes tsc over a generic `E`). Unregistered, non-respondable
165
- // errors fall through to the 500 default.
166
- Effect.catchIf(
167
- (_e): _e is E => true,
168
- (e): Effect.Effect<unknown, FailureResponse> => {
169
- // Registered domain error → remap. A library-error return is processed
170
- // by the internal dispatch; an `Effect` return is used as-is.
171
- if (isUserError(e)) {
172
- const out = userHandlers[e._tag](e);
173
- return isRouteError(out) ? processRouteError(out) : out;
174
- }
175
- // A library route error raised directly in the loader recover/throw.
176
- if (isRouteError(e)) return processRouteError(e);
177
- // Respondable render its own response and throw it.
178
- if (HttpServerRespondable.isRespondable(e)) {
179
- return HttpServerRespondable.toResponse(e).pipe(
180
- Effect.flatMap((res) =>
181
- Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
182
- ),
183
- );
184
- }
185
- return internalServerError();
186
- },
166
+ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
167
+ return function defineErrorHandlers<const Handlers>(
168
+ config: { errorHandlers: Handlers },
169
+ // Validation: a handler for a non-domain error, or one that doesn't return a
170
+ // route error / failing `Effect`, makes this rest parameter required and forces
171
+ // a compile error at the call.
172
+ ..._validate: HandlersAreValid<Handlers, DomainError> extends true
173
+ ? []
174
+ : [
175
+ eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
176
+ ]
177
+ ) {
178
+ const isUserError = (e: unknown): e is DomainError =>
179
+ typeof e === "object" &&
180
+ e !== null &&
181
+ "_tag" in e &&
182
+ (e as Tagged)._tag in (config.errorHandlers as object);
183
+
184
+ // Uniform call signature for dispatch (the per-tag handler types are narrower).
185
+ const userHandlers = config.errorHandlers as unknown as Record<
186
+ string,
187
+ ErrorHandler<DomainError>
188
+ >;
189
+
190
+ function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
191
+ fn: (args: Args) => Effect.Effect<A, E>,
192
+ // If the effect can still fail with something the library won't handle — a
193
+ // service-specific error that isn't a declared domain error, a library route
194
+ // error, or respondable this rest parameter becomes required and the call
195
+ // fails to type-check, forcing the loader/action to handle it.
196
+ ..._handle: [Unhandled<E, DomainError>] extends [never]
197
+ ? []
198
+ : [
199
+ theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
200
+ E,
201
+ DomainError
202
+ >,
203
+ ]
204
+ ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
205
+ return (args: Args) =>
206
+ // The internal channel is deliberately loose (`unknown` success); the outer
207
+ // cast restores the precise resolved type (computed from `E` and the handler
208
+ // map). Sound at runtime — the values produced are exactly those types.
209
+ Effect.runPromise(
210
+ fn(args).pipe(
211
+ // Catch the whole error channel and dispatch. The refinement is `e is E`
212
+ // (provably E, and a *refinement* not a bare predicate — the predicate
213
+ // overload crashes tsc over a generic `E`). A declared domain error with
214
+ // no handler (and not respondable) falls through to the 500 default.
215
+ Effect.catchIf(
216
+ (_e): _e is E => true,
217
+ (e): Effect.Effect<unknown, FailureResponse> => {
218
+ // Registered domain error → remap. A library-error return is processed
219
+ // by the internal dispatch; an `Effect` return is used as-is.
220
+ if (isUserError(e)) {
221
+ const out = userHandlers[e._tag](e);
222
+ return isRouteError(out) ? processRouteError(out) : out;
223
+ }
224
+ // A library route error raised directly in the loader → recover/throw.
225
+ if (isRouteError(e)) return processRouteError(e);
226
+ // Respondable → render its own response and throw it.
227
+ if (HttpServerRespondable.isRespondable(e)) {
228
+ return HttpServerRespondable.toResponse(e).pipe(
229
+ Effect.flatMap((res) =>
230
+ Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
231
+ ),
232
+ );
233
+ }
234
+ return internalServerError();
235
+ },
236
+ ),
187
237
  ),
188
- ),
189
- ) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
190
- }
191
-
192
- return {
193
- makeLoaderOrAction,
194
- makeLoader: makeLoaderOrAction,
195
- makeAction: makeLoaderOrAction,
238
+ ) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
239
+ }
240
+
241
+ return {
242
+ makeLoader: makeLoaderOrAction,
243
+ makeAction: makeLoaderOrAction,
244
+ };
196
245
  };
197
246
  }