feat(effect): add createEffectClient and hidden ORPCError-aware catch utilities - #1965
Conversation
… utilities createEffectClient wraps a client so every procedure returns a lazy Effect: outputs land in the success channel, typed errors in the error channel, retries re-invoke the client, and interruption aborts the call. The catch utilities no longer trust Extract alone: failure types that are not ORPCError (like Error) can still hold ORPCError instances at runtime, so handlers now also receive those hidden errors with unknown code and data, and code restrictions lift accordingly. Fully typed data remains available on pure ORPCError channels and via isInferableError.
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Reviewed the full diff of commit fa1df6d1 (10 files).
createEffectClient(packages/effect/src/client.ts): a proxy that wraps every oRPC procedure as a lazyEffect.Effect<TOutput, TError>viaEffect.tryPromise, merging the Effect interruption signal with the user's signal throughanyAbortSignal, and caching recursively-proxied nested clients for referentially stable accessors.catchORPCError*type soundness (packages/effect/src/error.ts): runtime bodies are unchanged; only the types now account for hiddenORPCErrors when a non-ORPCErrorfailure type (Error,TypeError,string,unknown) is in the channel — handlers widened toORPCError<...unknown>, codes/keys unrestricted, while pureORPCErrorchannels keep exact typeddataand restricted codes.- New
@orpc/clientdependency + tsconfig reference andindex.tsexport. - Docs: "Client Calls" section and a hidden-error note in
apps/content/docs/integrations/effect.mdx; both#client-callsand#catching-orpcerrorsbacklink anchors verified present. - Tests: new runtime + exact-type tests for the client, and extended
error.test-d.tshidden-error cases.
I verified pnpm --filter @orpc/experimental-effect type:check (covers all *.test-d.ts) and the effect runtime tests pass. I also traced the conditional types by hand — the E extends AnyORPCError ? ... and E extends ORPCError<infer TCode, any> ? ... conditionals distribute over the error-channel union, so pure ORPCError channels keep precise typed data and restricted code suggestions while mixed/Error/unknown channels widen honestly. The runtime matching (via instanceof ORPCError) already handled hidden errors, so this change makes the types consistent with actual behavior rather than altering runtime semantics — a genuine soundness improvement, and the isInferableError escape hatch keeps fully-typed recovery available where it's justified.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental review of the two commits since the prior pullfrog review (fa1df6d).
bc5945badocs:apps/content/docs/integrations/effect.mdxnow explicitly statescreateEffectClientwraps any oRPC client, server-side or client-side.02e9e6c2test:packages/effect/src/client.test.tsadds a runtime test pinningRECURSIVE_CLIENT_UNWRAP_KEYSbehavior —then/bind/valueOf/toString/toJSONresolve to the raw accessor (no recursive proxy),await effectClientis not thenable and resolves to itself, and accessors stay referentially stable.
I verified the new test (8 client runtime tests) and pnpm --filter @orpc/experimental-effect type:check both pass on the new head. The unwrap-key test is meaningful: its assertions mirror the actual key set in packages/client/src/consts.ts, so it would genuinely fail if the proxy over-wrapped then into a thenable or regressed stable-accessor caching. No new issues in this delta.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…idden Guards the hidden-ORPCError fallback with AnyORPCError extends E in catchORPCErrorCode handlers, catchORPCErrorCodes handlers, and the undeclared-key restriction, matching catchORPCError. Channels whose non-ORPCError members cannot hold an ORPCError (e.g. string literals) keep fully typed data and restricted codes/keys.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — Incremental review of commit 2f62e689 (the delta since the prior pullfrog review at 02e9e6c), which refines the hidden-ORPCError widening.
- Precision fix to the catch utilities (
packages/effect/src/error.ts): the non-ORPCErrorbranch of each handler type changed from an unconditionalORPCError<...unknown>toAnyORPCError extends E ? ORPCError<...unknown> : never. SinceORPCError extends Error, channels holding object supertypes (Error,TypeError,unknown) still widen, while primitive channels (string,'boom') — which structurally cannot contain anORPCErrorinstance — now keep typeddataand restricted codes/keys. - Exact-type tests (
error.test-d.ts): cover both directions, including@ts-expect-errorassertions that undeclared codes/keys stay rejected on channels that cannot hold hidden errors,unknown-channel widening, and updated result-channelExcludetypes.
I traced each new conditional by hand over the distributed error-channel union (e.g. ORPCError<'NOT_FOUND', { id: string }> | 'boom' keeps the handler at exactly ORPCError<'NOT_FOUND', { id: string }>, while ... | Error widens to | ORPCError<ORPCErrorCode, unknown>), and pnpm --filter @orpc/experimental-effect type:check passes on the new head. This closes an over-widening gap from the initial revision — channels typed with string/number failures previously lost their typed data for no reason. No runtime behavior changed.
ℹ️ Stale PR description
The PR body still advertises the broader widening, including explicitly that string channels widen. Commit 2f62e689 deliberately removed that: only failure types that structurally hold an ORPCError (object supertypes, unknown) trigger the hidden-error branch. A one-line update to the PR description would keep it aligned with the shipped behavior.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — one minor docs coverage note.
Reviewed changes — Incremental review of commit 2aa9d4a00 (the delta since the prior pullfrog review at 2f62e689).
- Simplified
callAsEffect(packages/effect/src/client.ts): dropped the now-unneededClientOptions/PromiseWithErrorimports and thecallcast —Effect.tryPromise.tryinvokesclient(input, ...)directly,resolveClientRest(rest)relies on inferred type args, and the proxy passes...reststraight through (droppingas [unknown]). Verifiedpnpm --filter @orpc/experimental-effect type:check(tsc -b, covers all*.test-d.ts) and all 28 effect runtime tests pass on the new head. - Removed the hidden-
ORPCErrorcaveat paragraph from the "Catching ORPCErrors" section ofapps/content/docs/integrations/effect.mdx— the only guide-level explanation that channels holding types likeErrorcan carry hiddenORPCErrors and widen catch-handlerdatatounknown.
ℹ️ Docs coverage
That caveat paragraph was the only place in the guide explaining the hidden-ORPCError behavior this PR adds — that failure types like Error can hold ORPCError instances at runtime, so catch-handler data widens to unknown and undeclared codes/keys become catchable. The JSDoc in error.ts still documents it, but a reader following the guide's own catchORPCErrorCode('NOT_FOUND', error => Effect.succeed(error.data.id)) example against a channel that includes Error will hit unknown data with no guide-level explanation of why. Not blocking — the behavior is well-documented in the API reference and pinned by the type tests.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
Pull request overview
Adds lazy Effect-based oRPC clients and improves ORPCError catch utility type soundness.
Changes:
- Adds
createEffectClientwith cancellation, retries, and recursive proxying. - Widens catch utility types for hidden runtime
ORPCErrors. - Adds tests, dependencies, and integration documentation.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
packages/effect/src/client.ts |
Implements Effect client wrapper. |
packages/effect/src/client.test.ts |
Tests runtime client behavior. |
packages/effect/src/client.test-d.ts |
Tests client typings. |
packages/effect/src/error.ts |
Updates catch utility types. |
packages/effect/src/error.test-d.ts |
Tests hidden-error typing. |
packages/effect/src/index.ts |
Exports client APIs. |
packages/effect/package.json |
Adds client dependency. |
packages/effect/tsconfig.json |
Adds client project reference. |
apps/content/docs/integrations/effect.mdx |
Documents Effect client calls. |
pnpm-lock.yaml |
Updates dependency resolution. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| @@ -1,3 +1,4 @@ | |||
| export * from './client' | |||
| & { [K in E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never]?: string extends K | ||
| ? (error: never) => Effect.Effect<any, any, any> | ||
| : K extends infer C extends ORPCErrorCode | ||
| ? (error: E extends AnyORPCError ? Extract<E, ORPCError<C, any>> : AnyORPCError extends E ? ORPCError<C, unknown> : never) => Effect.Effect<any, any, any> |

Adds
createEffectClient, which wraps an oRPC client so every procedure returns a lazy Effect you canyield*inside Effect generators. Also makes thecatchORPCError*utilities honest about errors the type system cannot see: failure types likeErrorcan holdORPCErrorinstances at runtime, so handlers now account for them instead of pretending typed claims are the only possibility.Client calls
client.planet.find({ id })becomesyield* effectClient.planet.find({ id }): outputs land in the success channel, errors in the error channel with their original types preserved.Effect.retryre-invokes the client per attempt; interrupting the effect aborts the request, and a user-providedsignalis merged rather than replaced.createSafeClient).Catch utilities soundness
ORPCErrorfailure types, handlers also receive a hiddenORPCErrorwith unknown code and data,dataon code-matched handlers widens tounknown, and code/key restrictions lift (undeclared codes become catchable).ORPCErrorchannels keep exact typeddataand restricted code suggestions, so server handlers that only fail withORPCErrors lose nothing.Effect.catchIf(isInferableError, ...)remains the fully typed path, since its runtime check justifies the narrowing. Documented in the Effect integration guide.Testing
TypeError, string, andunknownchannels), the preserved typed behavior on pure channels, and the client-sideunknowndata +isInferableErrornarrowing.pnpm type:check, all effect package tests, lint, andpnpm docs:validatepass.