react-router-effect 0.6.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
 
package/dist/index.d.mts CHANGED
@@ -124,6 +124,35 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
124
124
  * errors. The loader/action must handle these itself (catch or map them).
125
125
  */
126
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
+ };
127
156
  /**
128
157
  * A React Router context key holding a per-request effect `Context.Context`. Set
129
158
  * it in middleware and pass it to the factory's `requestContext` — the runner
@@ -242,8 +271,8 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
242
271
  */
243
272
  respond?: ExtraRespond;
244
273
  }) => {
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>>;
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>>;
247
276
  Respond: {
248
277
  early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
249
278
  throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
package/dist/index.mjs CHANGED
@@ -119,9 +119,10 @@ function makeLoaderOrActionFactory() {
119
119
  };
120
120
  const userHandlers = config.errorHandlers ?? {};
121
121
  const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
122
- function makeLoaderOrAction(fn, ..._handle) {
122
+ function makeLoaderOrAction(fn) {
123
+ const run = fn;
123
124
  return (args) => {
124
- const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
125
+ const program = run(args).pipe(Effect.catchIf((_e) => true, (e) => {
125
126
  if (isUserError(e)) {
126
127
  const out = userHandlers[e._tag](e);
127
128
  return isRouteError(out) ? processRouteError(out) : out;
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.6.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/factory.ts CHANGED
@@ -112,6 +112,43 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
112
112
  */
113
113
  type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
114
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
+
115
152
  // ---------------------------------------------------------------------------
116
153
  // Internal runtime helpers.
117
154
  // ---------------------------------------------------------------------------
@@ -285,34 +322,31 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
285
322
  const isUserError = (e: unknown): e is DomainError =>
286
323
  typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
287
324
 
288
- function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
289
- // The effect may require `RServices` (provided by the configured `runtime`)
290
- // and `ReqServices` (provided per-request from `requestContext`). Both are
291
- // `never` when their source isn't configured, so the effect must require
292
- // nothing extra.
293
- fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>,
294
- // If the effect can still fail with something the library won't handle — a
295
- // service-specific error that isn't a declared domain error, a library route
296
- // error, or respondable — this rest parameter becomes required and the call
297
- // fails to type-check, forcing the loader/action to handle it.
298
- ..._handle: [Unhandled<E, DomainError>] extends [never]
299
- ? []
300
- : [
301
- theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
302
- E,
303
- DomainError
304
- >,
305
- ]
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>,
306
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>;
307
341
  return (args: Args) => {
308
342
  // The internal channel is deliberately loose (`unknown` success); the outer
309
343
  // cast restores the precise resolved type (computed from `E` and the handler
310
344
  // map). Sound at runtime — the values produced are exactly those types.
311
- const program = fn(args).pipe(
345
+ const program = run(args).pipe(
312
346
  // Catch the whole error channel and dispatch. The refinement is `e is E`
313
347
  // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
314
- // overload crashes tsc over a generic `E`). A declared domain error with
315
- // 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.
316
350
  Effect.catchIf(
317
351
  (_e): _e is E => true,
318
352
  (e): Effect.Effect<unknown, FailureResponse> => {