From fa1df6d1adc4dae8f46c201400703507ace884d3 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 15:39:32 +0700 Subject: [PATCH 1/6] feat(effect): add createEffectClient and hidden ORPCError-aware catch 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. --- apps/content/docs/integrations/effect.mdx | 42 +++- packages/effect/package.json | 1 + packages/effect/src/client.test-d.ts | 81 ++++++++ packages/effect/src/client.test.ts | 125 ++++++++++++ packages/effect/src/client.ts | 65 ++++++ packages/effect/src/error.test-d.ts | 101 ++++++++-- packages/effect/src/error.ts | 43 ++-- packages/effect/src/index.ts | 1 + packages/effect/tsconfig.json | 1 + pnpm-lock.yaml | 233 ++++++++++++++-------- 10 files changed, 573 insertions(+), 120 deletions(-) create mode 100644 packages/effect/src/client.test-d.ts create mode 100644 packages/effect/src/client.test.ts create mode 100644 packages/effect/src/client.ts diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index 436bfbaa0..ca840087e 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -1,6 +1,6 @@ --- title: "Effect Integration" -description: "Write effectful oRPC handlers with Effect generators, provide services through context, and use Effect Schema for input and output validation." +description: "Write effectful oRPC handlers with Effect generators, call oRPC clients as effects, provide services through context, and use Effect Schema for input and output validation." sidebar: label: "Effect" --- @@ -201,10 +201,50 @@ const handled = program.pipe( ) ``` +Failure types like `Error` can also hold `ORPCError` instances at runtime. When the error channel contains such types, these utilities accept any code and handlers also receive those hidden errors, so their `data` widens to `unknown`. On the client, combine with [isInferableError](/docs/client/error-handling#using-safe-and-isinferableerror) when you need fully typed data. + :::info All utilities support data-first `catchORPCError(program, handler)` and data-last `program.pipe(catchORPCError(handler))` styles. ::: +## Client Calls + +`createEffectClient` wraps a [client](/docs/client/client-side) so every procedure returns a lazy Effect instead of a promise, ready to `yield*` inside Effect generators. The output becomes the success value, and errors land in the error channel with their original types preserved, ready for utilities like `catchORPCErrorCodes`: + +```ts +import { catchORPCErrorCodes, createEffectClient } from '@orpc/experimental-effect' +import { Effect } from 'effect' + +const effectClient = createEffectClient(client) + +const program = Effect.gen(function* () { + const planet = yield* effectClient.planet.find({ id: 1 }) + return planet.name +}).pipe( + catchORPCErrorCodes({ + NOT_FOUND: error => Effect.succeed('unknown'), + }), +) +``` + +You can also combine `Effect.catchIf` with [isInferableError](/docs/client/error-handling#using-safe-and-isinferableerror) to recover from every inferable error in a typesafe way: + +```ts +import { isInferableError } from '@orpc/client' +import { Effect } from 'effect' + +const recovered = effectClient.planet.find({ id: 1 }).pipe( + Effect.catchIf(isInferableError, (error) => { + // error is fully typed here + return Effect.succeed(null) + }), +) +``` + +:::info +The effects are lazy: the client is invoked each time the effect runs, so they work naturally with `Effect.retry`. Interrupting the effect aborts the underlying call. +::: + ## Effect Schema oRPC natively supports [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec), and [Effect Schema](https://effect.website/docs/schema/introduction/) implements that spec through [Schema.toStandardSchemaV1](https://effect.website/docs/schema/standard-schema/): diff --git a/packages/effect/package.json b/packages/effect/package.json index 8468991bf..98db2b65a 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -63,6 +63,7 @@ "effect": ">=4.0.0-beta.90" }, "dependencies": { + "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/json-schema": "workspace:*", "@orpc/server": "workspace:*", diff --git a/packages/effect/src/client.test-d.ts b/packages/effect/src/client.test-d.ts new file mode 100644 index 000000000..ff7c453a5 --- /dev/null +++ b/packages/effect/src/client.test-d.ts @@ -0,0 +1,81 @@ +import type { Client, ClientContext, ORPCError } from '@orpc/client' +import type { EffectClient } from './client' +import { isInferableError } from '@orpc/client' +import { Effect } from 'effect' +import { createEffectClient } from './client' +import { catchORPCErrorCodes } from './error' + +describe('createEffectClient', () => { + const client = {} as { + ping: Client | ORPCError<'NOT_FOUND', { id: number }>> + optional: Client + nested: { + pong: Client<{ cache: boolean }, { id: number }, { result: string }, Error> + } + } + + const effectClient = createEffectClient(client) + + it('procedures return effects with output and error types preserved', () => { + expectTypeOf(effectClient.ping('test')).toEqualTypeOf< + Effect.Effect | ORPCError<'NOT_FOUND', { id: number }>> + >() + + expectTypeOf(effectClient.nested.pong({ id: 123 }, { context: { cache: true } })).toEqualTypeOf< + Effect.Effect<{ result: string }, Error> + >() + }) + + it('enforces input and options types', () => { + // @ts-expect-error - input must be a string + void effectClient.ping(123) + // @ts-expect-error - invalid context + void effectClient.nested.pong({ id: 123 }, { context: { cache: 'invalid' } }) + // @ts-expect-error - context is required + void effectClient.nested.pong({ id: 123 }) + }) + + it('allows omitting optional input', () => { + void effectClient.optional() + void effectClient.optional('input') + }) + + it('EffectClient type maps nested clients', () => { + expectTypeOf(effectClient).toEqualTypeOf>() + }) + + it('data is unknown when catching by code, because the error may be a hidden non-inferable ORPCError', () => { + const recovered = effectClient.ping('test').pipe( + catchORPCErrorCodes({ + BAD_GATEWAY: (error) => { + expectTypeOf(error.code).toEqualTypeOf<'BAD_GATEWAY'>() + expectTypeOf(error.data).toEqualTypeOf() + + return Effect.succeed('recovered' as const) + }, + }), + ) + + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect> + >() + }) + + it('combines isInferableError with code narrowing for typesafe error data', () => { + const recovered = effectClient.ping('test').pipe( + Effect.catchIf(isInferableError, (error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'BAD_GATEWAY', { val: string }> | ORPCError<'NOT_FOUND', { id: number }> + >() + + if (error.code === 'BAD_GATEWAY') { + expectTypeOf(error.data).toEqualTypeOf<{ val: string }>() + } + + return Effect.succeed('recovered' as const) + }), + ) + + expectTypeOf(recovered).toEqualTypeOf>() + }) +}) diff --git a/packages/effect/src/client.test.ts b/packages/effect/src/client.test.ts new file mode 100644 index 000000000..969969322 --- /dev/null +++ b/packages/effect/src/client.test.ts @@ -0,0 +1,125 @@ +import type { Client, ClientContext, ORPCError } from '@orpc/client' +import { sleep } from '@orpc/shared' +import { Effect, Exit, Schedule } from 'effect' +import { createEffectClient } from './client' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createEffectClient', () => { + const pingFn = vi.fn() + const pongFn = vi.fn() + + const client = { + ping: pingFn, + nested: { + pong: pongFn, + }, + invalid: 'invalid', + } as unknown as { + ping: Client> + nested: { + pong: Client + } + } + + const effectClient = createEffectClient(client) + + it('procedures return yieldable effects', async () => { + pingFn.mockResolvedValue(42) + pongFn.mockResolvedValue({ result: 'pong' }) + + const program = Effect.gen(function* () { + const ping = yield* effectClient.ping('input') + const pong = yield* effectClient.nested.pong({ id: 123 }) + return [ping, pong] + }) + + await expect(Effect.runPromise(program)).resolves.toEqual([42, { result: 'pong' }]) + + expect(pingFn).toHaveBeenCalledWith('input', { + context: {}, + signal: expect.any(AbortSignal), + }) + expect(pongFn).toHaveBeenCalledWith({ id: 123 }, { + context: {}, + signal: expect.any(AbortSignal), + }) + }) + + it('forwards context, lastEventId, and merges the signal', async () => { + pingFn.mockResolvedValue(42) + const controller = new AbortController() + + await Effect.runPromise(effectClient.ping('input', { + context: { cache: true }, + lastEventId: 'id-1', + signal: controller.signal, + })) + + const options = pingFn.mock.calls[0]![1] + expect(options.context).toEqual({ cache: true }) + expect(options.lastEventId).toBe('id-1') + expect(options.signal).toBeInstanceOf(AbortSignal) + expect(options.signal.aborted).toBe(false) + + controller.abort() + expect(options.signal.aborted).toBe(true) + }) + + it('captures rejections in the error channel', async () => { + const error = new Error('__TEST__') + pingFn.mockRejectedValue(error) + + const exit = await Effect.runPromiseExit(effectClient.ping('input')) + + expect(exit).toEqual(Exit.fail(error)) + }) + + it('procedures are lazy and re-invoke the client on retry', async () => { + pingFn + .mockRejectedValueOnce(new Error('__TEST__')) + .mockRejectedValueOnce(new Error('__TEST__')) + .mockResolvedValueOnce(42) + + const effect = effectClient.ping('input') + await sleep(0) + expect(pingFn).not.toHaveBeenCalled() + + await expect( + Effect.runPromise(effect.pipe(Effect.retry(Schedule.recurs(2)))), + ).resolves.toBe(42) + + expect(pingFn).toHaveBeenCalledTimes(3) + }) + + it('aborts the call when the effect is interrupted', async () => { + let clientSignal: AbortSignal | undefined + pingFn.mockImplementation((_input, options) => { + clientSignal = options.signal + return new Promise(() => {}) + }) + + const signal = AbortSignal.timeout(10) + const exit = await Effect.runPromiseExit(effectClient.ping('input'), { signal }) + + expect(Exit.hasInterrupts(exit)).toBe(true) + expect(clientSignal!.aborted).toBe(true) + }) + + it('returns strictly equal client on repeated access', () => { + expect(effectClient.ping).toBe(effectClient.ping) + expect(effectClient.nested).toBe(effectClient.nested) + expect(effectClient.nested.pong).toBe(effectClient.nested.pong) + expect(effectClient.nested.pong).not.toBe(effectClient.ping as unknown) + + expect(createEffectClient(client)).not.toBe(effectClient) + }) + + it('not proxy on non-object or symbol properties', () => { + expect((effectClient as any).invalid).toBe('invalid') + expect((effectClient as any)[Symbol('test')]).toEqual(undefined) + expect((effectClient.nested as any)[Symbol('test')]).toEqual(undefined) + }) +}) diff --git a/packages/effect/src/client.ts b/packages/effect/src/client.ts new file mode 100644 index 000000000..efde4f689 --- /dev/null +++ b/packages/effect/src/client.ts @@ -0,0 +1,65 @@ +import type { AnyNestedClient, Client, ClientContext, ClientOptions, ClientRest } from '@orpc/client' +import type { PromiseWithError } from '@orpc/shared' +import { RECURSIVE_CLIENT_UNWRAP_KEYS, resolveClientRest } from '@orpc/client' +import { anyAbortSignal, isTypescriptObject } from '@orpc/shared' +import { Effect } from 'effect' + +function callAsEffect( + client: Client, + ...rest: ClientRest +): Effect.Effect { + const call = client as (input: TInput, options: ClientOptions) => PromiseWithError + const [input, options] = resolveClientRest(rest) + + return Effect.tryPromise({ + try: signal => call(input, { + ...options, + signal: anyAbortSignal([options.signal, signal]), + }), + catch: error => error as TError, + }) +} + +export type EffectClient + = T extends Client + ? (...rest: ClientRest) => Effect.Effect + : { + [K in keyof T]: T[K] extends AnyNestedClient ? EffectClient : never + } + +/** + * Creates a client whose procedures return lazy effects instead of promises, + * so you can `yield*` calls inside Effect generators. Errors are captured in + * the error channel with their original types preserved, and interrupting an + * effect aborts the underlying call. + * + * @see {@link https://orpc.dev/docs/integrations/effect#client-calls | Effect Integration - Client Calls} + */ +export function createEffectClient(client: T): EffectClient { + const cache = new Map>() + + const proxy = new Proxy((...rest: any[]) => callAsEffect(client as Client, ...rest as [unknown]), { + get(target, prop) { + if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { + return Reflect.get(target, prop) + } + + let effectClient = cache.get(prop) + + if (effectClient === undefined) { + const value = (client as Record)[prop] + + if (!isTypescriptObject(value)) { + return value + } + + effectClient = createEffectClient(value as AnyNestedClient) + cache.set(prop, effectClient) + } + + return effectClient + }, + }) + + return proxy as EffectClient +} diff --git a/packages/effect/src/error.test-d.ts b/packages/effect/src/error.test-d.ts index dae928446..7cf10dd98 100644 --- a/packages/effect/src/error.test-d.ts +++ b/packages/effect/src/error.test-d.ts @@ -1,4 +1,4 @@ -import type { ORPCError } from '@orpc/server' +import type { ORPCError, ORPCErrorCode } from '@orpc/server' import { Effect } from 'effect' import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error' @@ -12,7 +12,13 @@ class Service2 { const effect = {} as Effect.Effect< 'output', - ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number> | TypeError, + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, + Service1 +> + +const effectWithHidden = {} as Effect.Effect< + 'output', + ORPCError<'NOT_FOUND', { id: string }> | Error, Service1 > @@ -23,7 +29,7 @@ describe('catchORPCError', () => { return Effect.succeed('recovered' as const) })) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf>() }) it('catches every ORPCError and excludes them from the error channel (data-first)', () => { @@ -32,7 +38,7 @@ describe('catchORPCError', () => { return Effect.succeed('recovered' as const) }) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf>() }) it('merges handler error and requirement channels into the result', () => { @@ -40,14 +46,32 @@ describe('catchORPCError', () => { catchORPCError(() => ({} as Effect.Effect<'recovered', RangeError, Service2>)), ) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf>() + }) + + it('also receives hidden ORPCErrors, with unknown code and data', () => { + const recovered = effectWithHidden.pipe(catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf | ORPCError>() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + })) + + expectTypeOf(recovered).toEqualTypeOf>() }) - it('handler receives never when the error channel contains no ORPCError', () => { - const safe = {} as Effect.Effect<'output', TypeError> + it('treats every non-ORPCError failure type as possibly hiding an ORPCError', () => { + const withTypeError = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | TypeError> + + void withTypeError.pipe(catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf | ORPCError>() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + })) + + const withString = {} as Effect.Effect<'output', 'boom'> - void safe.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf() + void withString.pipe(catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf>() return Effect.succeed('recovered' as const) })) }) @@ -60,7 +84,7 @@ describe('catchORPCErrorCode', () => { return Effect.succeed('recovered' as const) })) - expectTypeOf(recovered).toEqualTypeOf | TypeError, Service1>>() + expectTypeOf(recovered).toEqualTypeOf, Service1>>() }) it('catches ORPCErrors with a matching code and excludes them from the error channel (data-first)', () => { @@ -69,7 +93,7 @@ describe('catchORPCErrorCode', () => { return Effect.succeed('recovered' as const) }) - expectTypeOf(recovered).toEqualTypeOf | TypeError, Service1>>() + expectTypeOf(recovered).toEqualTypeOf, Service1>>() }) it('merges handler error and requirement channels into the result', () => { @@ -78,19 +102,19 @@ describe('catchORPCErrorCode', () => { ) expectTypeOf(recovered).toEqualTypeOf< - Effect.Effect<'output' | 'recovered', ORPCError<'CONFLICT', number> | RangeError | TypeError, Service1 | Service2> + Effect.Effect<'output' | 'recovered', ORPCError<'CONFLICT', number> | RangeError, Service1 | Service2> >() }) it('supports custom error codes', () => { - const custom = {} as Effect.Effect<'output', ORPCError<'__CUSTOM__', undefined> | TypeError> + const custom = {} as Effect.Effect<'output', ORPCError<'__CUSTOM__', undefined>> const recovered = custom.pipe(catchORPCErrorCode('__CUSTOM__', (error) => { expectTypeOf(error).toEqualTypeOf>() return Effect.succeed('recovered' as const) })) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf>() }) it('suggests and restricts the code to those present in the error channel', () => { @@ -103,6 +127,36 @@ describe('catchORPCErrorCode', () => { // @ts-expect-error - code must be a string void effect.pipe(catchORPCErrorCode(123, () => Effect.succeed('recovered'))) }) + + it('widens data to unknown on declared codes when the channel can hold hidden ORPCErrors', () => { + const recovered = effectWithHidden.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf | ORPCError<'NOT_FOUND', unknown>>() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + })) + + expectTypeOf(recovered).toEqualTypeOf>() + }) + + it('accepts undeclared codes when the channel can hold hidden ORPCErrors', () => { + const recovered = effectWithHidden.pipe(catchORPCErrorCode('CONFLICT', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('recovered' as const) + })) + + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | Error, Service1> + >() + }) + + it('unknown error channels can also hold hidden ORPCErrors', () => { + const withUnknown = {} as Effect.Effect<'output', unknown> + + void withUnknown.pipe(catchORPCErrorCode('CONFLICT', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('recovered' as const) + })) + }) }) describe('catchORPCErrorCodes', () => { @@ -118,7 +172,7 @@ describe('catchORPCErrorCodes', () => { }, })) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf>() }) it('keeps unhandled codes in the error channel (data-first)', () => { @@ -129,7 +183,7 @@ describe('catchORPCErrorCodes', () => { }, }) - expectTypeOf(recovered).toEqualTypeOf | TypeError, Service1>>() + expectTypeOf(recovered).toEqualTypeOf, Service1>>() }) it('suggests and restricts keys to the codes present in the error channel', () => { @@ -139,4 +193,19 @@ describe('catchORPCErrorCodes', () => { // @ts-expect-error - BAD_GATEWAY is not present in the error channel (data-first) void catchORPCErrorCodes(effect, { BAD_GATEWAY: () => Effect.succeed('recovered') }) }) + + it('widens data to unknown and accepts undeclared keys when the channel can hold hidden ORPCErrors', () => { + const recovered = effectWithHidden.pipe(catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('nf' as const) + }, + CONFLICT: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('cf' as const) + }, + })) + + expectTypeOf(recovered).toEqualTypeOf>() + }) }) diff --git a/packages/effect/src/error.ts b/packages/effect/src/error.ts index 8e43e7689..34cb779bf 100644 --- a/packages/effect/src/error.ts +++ b/packages/effect/src/error.ts @@ -5,7 +5,9 @@ import { Effect, Function } from 'effect' /** * Recovers from `ORPCError` failures in the error channel of an effect. - * Other failures re-fail with their original cause. + * Other failures re-fail with their original cause. Because failure types + * like `Error` can also hold `ORPCError` instances at runtime, the handler + * can also receive such hidden errors with unknown code and data. * * Supports both data-first `catchORPCError(effect, handler)` and data-last * `effect.pipe(catchORPCError(handler))` styles. @@ -23,11 +25,11 @@ import { Effect, Function } from 'effect' */ export const catchORPCError: { ( - f: (error: Extract) => Effect.Effect, + f: (error: E extends AnyORPCError ? E : ORPCError) => Effect.Effect, ): (self: Effect.Effect) => Effect.Effect, R | R2> ( self: Effect.Effect, - f: (error: Extract) => Effect.Effect, + f: (error: E extends AnyORPCError ? E : ORPCError) => Effect.Effect, ): Effect.Effect, R | R2> } = Function.dual( 2, @@ -42,7 +44,9 @@ export const catchORPCError: { /** * Recovers from `ORPCError` failures with a matching code in the error channel of an effect. * Other failures re-fail with their original cause. The `code` argument is - * suggested and restricted to the codes present in the error channel. + * suggested and restricted to the codes present in the error channel, and is + * unrestricted when the channel contains failure types like `Error` that can + * hold hidden `ORPCError` instances, whose data is unknown. * * Supports both data-first `catchORPCErrorCode(effect, code, handler)` and * data-last `effect.pipe(catchORPCErrorCode(code, handler))` styles. @@ -51,7 +55,7 @@ export const catchORPCError: { * ```ts * declare const program: Effect.Effect< * string, - * ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', undefined> | TypeError + * ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', undefined> * > * * const recovered = program.pipe( @@ -62,14 +66,14 @@ export const catchORPCError: { * @see {@link https://orpc.dev/docs/integrations/effect#catching-orpcerrors | Effect Integration - Catching ORPCErrors} */ export const catchORPCErrorCode: { - ? TCode : never, E, A2, E2, R2>( + ? TCode : ORPCErrorCode, E, A2, E2, R2>( code: TCode, - f: (error: Extract>) => Effect.Effect, + f: (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect, ): (self: Effect.Effect) => Effect.Effect>, R | R2> - ? TCode : never, A2, E2, R2>( + ? TCode : ORPCErrorCode, A2, E2, R2>( self: Effect.Effect, code: TCode, - f: (error: Extract>) => Effect.Effect, + f: (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect, ): Effect.Effect>, R | R2> } = Function.dual( 3, @@ -86,7 +90,8 @@ export const catchORPCErrorCode: { * Recovers from `ORPCError` failures using a map of codes to handlers in the * error channel of an effect. Other failures re-fail with their original * cause. The keys are suggested and restricted to the codes present in the - * error channel. + * error channel, and are unrestricted when the channel contains failure types + * like `Error` that can hold hidden `ORPCError` instances, whose data is unknown. * * Supports both data-first `catchORPCErrorCodes(effect, cases)` and data-last * `effect.pipe(catchORPCErrorCodes(cases))` styles. @@ -95,7 +100,7 @@ export const catchORPCErrorCode: { * ```ts * declare const program: Effect.Effect< * string, - * ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', undefined> | TypeError + * ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', undefined> * > * * const recovered = program.pipe( @@ -112,8 +117,12 @@ export const catchORPCErrorCodes: { < E, Cases extends - & { [K in Extract['code']]?: (error: Extract>) => Effect.Effect } - & (unknown extends E ? object : { [K in Exclude['code']>]: never }), + & { [K in E extends ORPCError ? TCode : ORPCErrorCode]?: string extends K + ? (error: never) => Effect.Effect + : K extends infer C extends ORPCErrorCode + ? (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect + : never } + & { [K in Exclude ? TCode : ORPCErrorCode>]: never }, >( cases: Cases, ): (self: Effect.Effect) => Effect.Effect< @@ -127,8 +136,12 @@ export const catchORPCErrorCodes: { E, R, Cases extends - & { [K in Extract['code']]?: (error: Extract>) => Effect.Effect } - & (unknown extends E ? object : { [K in Exclude['code']>]: never }), + & { [K in E extends ORPCError ? TCode : ORPCErrorCode]?: string extends K + ? (error: never) => Effect.Effect + : K extends infer C extends ORPCErrorCode + ? (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect + : never } + & { [K in Exclude ? TCode : ORPCErrorCode>]: never }, >( self: Effect.Effect, cases: Cases, diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index ff02f9a5e..c5a7c431a 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -1,3 +1,4 @@ +export * from './client' export * from './context' export * from './converter' export * from './error' diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json index 9e694d2aa..fe827abfe 100644 --- a/packages/effect/tsconfig.json +++ b/packages/effect/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.lib.json", "references": [ + { "path": "../client" }, { "path": "../contract" }, { "path": "../server" }, { "path": "../json-schema" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9130de69..676a5d5ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,7 +360,7 @@ importers: version: 26.4.0 blume: specifier: ^1.5.3 - version: 1.5.3(e4d105bdff9514874e7c901a765e4db3) + version: 1.5.3(@astrojs/cloudflare@14.2.5(@types/node@26.4.0)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(wrangler@4.126.0)(yaml@2.9.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)) effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 @@ -517,6 +517,9 @@ importers: packages/effect: dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client '@orpc/contract': specifier: workspace:* version: link:../contract @@ -12766,9 +12769,9 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@astrojs/check@0.9.10(prettier@3.9.6)(typescript@6.0.3)': + '@astrojs/check@0.9.10(typescript@6.0.3)': dependencies: - '@astrojs/language-server': 2.16.15(prettier@3.9.6)(typescript@6.0.3) + '@astrojs/language-server': 2.16.15(typescript@6.0.3) chokidar: 4.0.3 kleur: 4.1.5 typescript: 6.0.3 @@ -12869,7 +12872,7 @@ snapshots: smol-toml: 1.8.0 unified: 11.0.5 - '@astrojs/language-server@2.16.15(prettier@3.9.6)(typescript@6.0.3)': + '@astrojs/language-server@2.16.15(typescript@6.0.3)': dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/yaml2ts': 0.2.4 @@ -12883,14 +12886,12 @@ snapshots: volar-service-css: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) volar-service-emmet: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) volar-service-html: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) - volar-service-prettier: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.6) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) volar-service-typescript: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) volar-service-yaml: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) vscode-html-languageservice: 5.6.2 vscode-uri: 3.2.0 - optionalDependencies: - prettier: 3.9.6 transitivePeerDependencies: - typescript @@ -12923,11 +12924,11 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(supports-color@10.2.2)': + '@astrojs/mdx@7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/markdown-remark': 7.2.4(supports-color@10.2.2) - '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) + '@mdx-js/mdx': 3.1.1 acorn: 8.18.0 astro: 7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0) es-module-lexer: 2.3.2 @@ -12945,7 +12946,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@11.1.4(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(supports-color@10.2.2)': + '@astrojs/node@11.1.4(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.4 astro: 7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0) @@ -12958,12 +12959,12 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@6.0.4(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(terser@5.51.1)(yaml@2.9.0)': + '@astrojs/react@6.0.4(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@astrojs/internal-helpers': 0.10.4 '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@vitejs/plugin-react': 5.2.0(supports-color@10.2.2)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) + '@vitejs/plugin-react': 5.2.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) devalue: 5.9.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -12993,12 +12994,12 @@ snapshots: '@astrojs/underscore-redirects@1.0.4': {} - '@astrojs/vercel@11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rollup@4.63.0)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3)': + '@astrojs/vercel@11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': dependencies: '@astrojs/internal-helpers': 0.10.4 - '@vercel/analytics': 1.6.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) + '@vercel/analytics': 1.6.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) '@vercel/functions': 3.9.5(ws@8.21.3) - '@vercel/nft': 1.11.0(rollup@4.63.0)(supports-color@10.2.2) + '@vercel/nft': 1.11.0 '@vercel/routing-utils': 5.3.3 astro: 7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0) esbuild: 0.28.2 @@ -13071,6 +13072,26 @@ snapshots: '@babel/compat-data@7.29.7': {} + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 @@ -13125,6 +13146,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@babel/helper-plugin-utils@7.29.7': {} '@babel/helper-string-parser@7.29.7': {} @@ -13145,14 +13175,14 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime-corejs3@7.29.7': @@ -14494,7 +14524,7 @@ snapshots: '@lukeed/csprng@1.1.0': {} - '@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)': + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 detect-libc: 2.1.2 @@ -14509,7 +14539,7 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} - '@mdx-js/mdx@3.1.1(supports-color@10.2.2)': + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -14521,13 +14551,13 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.1 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6(supports-color@10.2.2) + hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.18.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0(supports-color@10.2.2) - remark-mdx: 3.1.1(supports-color@10.2.2) + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 remark-parse: 11.0.0(supports-color@10.2.2) remark-rehype: 11.1.2 source-map: 0.7.6 @@ -14554,7 +14584,7 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': dependencies: '@hono/node-server': 2.1.1(hono@4.13.5) ajv: 8.20.0 @@ -14565,7 +14595,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + express-rate-limit: 8.6.2(express@5.2.1) hono: 4.13.5 jose: 6.2.10 json-schema-typed: 8.0.2 @@ -15629,10 +15659,10 @@ snapshots: '@phosphor-icons/core@2.1.1': {} - '@pierre/diffs@1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@pierre/diffs@1.3.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@pierre/theme': 2.0.0 - '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3) + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3) '@shikijs/transformers': 4.4.3 diff: 9.0.0 hast-util-to-html: 9.0.5 @@ -15645,10 +15675,9 @@ snapshots: '@pierre/theme@2.0.0': {} - '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)': + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)': optionalDependencies: '@pierre/theme': 2.0.0 - '@shikijs/themes': 4.4.3 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) shiki: 4.4.3 @@ -16292,11 +16321,11 @@ snapshots: '@shikijs/core': 4.4.3 '@shikijs/types': 4.4.3 - '@shikijs/twoslash@4.4.3(supports-color@10.2.2)(typescript@6.0.3)': + '@shikijs/twoslash@4.4.3(typescript@6.0.3)': dependencies: '@shikijs/core': 4.4.3 '@shikijs/types': 4.4.3 - twoslash: 0.3.9(supports-color@10.2.2)(typescript@6.0.3) + twoslash: 0.3.9(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -17082,7 +17111,7 @@ snapshots: '@takumi-rs/core-win32-x64-msvc@2.12.0': optional: true - '@takumi-rs/core@2.12.0(csstype@3.2.3)(react@19.2.8)': + '@takumi-rs/core@2.12.0(react@19.2.8)': dependencies: '@takumi-rs/helpers': 2.12.0(react@19.2.8) optionalDependencies: @@ -17094,7 +17123,6 @@ snapshots: '@takumi-rs/core-linux-x64-musl': 2.12.0 '@takumi-rs/core-win32-arm64-msvc': 2.12.0 '@takumi-rs/core-win32-x64-msvc': 2.12.0 - csstype: 3.2.3 transitivePeerDependencies: - preact - react @@ -17103,11 +17131,9 @@ snapshots: optionalDependencies: react: 19.2.8 - '@takumi-rs/wasm@2.12.0(csstype@3.2.3)(react@19.2.8)': + '@takumi-rs/wasm@2.12.0(react@19.2.8)': dependencies: '@takumi-rs/helpers': 2.12.0(react@19.2.8) - optionalDependencies: - csstype: 3.2.3 transitivePeerDependencies: - preact - react @@ -17676,7 +17702,7 @@ snapshots: '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 - '@typescript/vfs@1.6.4(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript/vfs@1.6.4(typescript@6.0.3)': dependencies: debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 @@ -17713,16 +17739,14 @@ snapshots: dependencies: valibot: 1.4.2(typescript@6.0.3) - '@vercel/analytics@1.6.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': + '@vercel/analytics@1.6.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': optionalDependencies: - next: 16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 svelte: 5.56.10(@typescript-eslint/types@8.68.0) vue: 3.5.42(typescript@6.0.3) - '@vercel/analytics@2.0.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': + '@vercel/analytics@2.0.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': optionalDependencies: - next: 16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 svelte: 5.56.10(@typescript-eslint/types@8.68.0) vue: 3.5.42(typescript@6.0.3) @@ -17742,9 +17766,9 @@ snapshots: optionalDependencies: ws: 8.21.3 - '@vercel/nft@1.11.0(rollup@4.63.0)(supports-color@10.2.2)': + '@vercel/nft@1.11.0': dependencies: - '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) + '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.4.0(rollup@4.63.0) acorn: 8.18.0 acorn-import-attributes: 1.9.5(acorn@8.18.0) @@ -17788,11 +17812,11 @@ snapshots: optionalDependencies: ajv: 6.15.0 - '@vitejs/plugin-react@5.2.0(supports-color@10.2.2)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -18532,31 +18556,31 @@ snapshots: blake3-wasm@2.1.5: {} - blume@1.5.3(e4d105bdff9514874e7c901a765e4db3): + blume@1.5.3(@astrojs/cloudflare@14.2.5(@types/node@26.4.0)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(wrangler@4.126.0)(yaml@2.9.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)): dependencies: - '@astrojs/check': 0.9.10(prettier@3.9.6)(typescript@6.0.3) + '@astrojs/check': 0.9.10(typescript@6.0.3) '@astrojs/markdown-satteri': 0.3.8 - '@astrojs/mdx': 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(supports-color@10.2.2) - '@astrojs/node': 11.1.4(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(supports-color@10.2.2) - '@astrojs/react': 6.0.4(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(terser@5.51.1)(yaml@2.9.0) - '@astrojs/vercel': 11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rollup@4.63.0)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3) + '@astrojs/mdx': 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) + '@astrojs/node': 11.1.4(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) + '@astrojs/react': 6.0.4(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@astrojs/vercel': 11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) '@asyncapi/converter': 2.0.2 '@clack/prompts': 1.7.0 '@iconify-json/lucide': 1.2.126 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.4 - '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) '@orama/orama': 3.1.18 - '@pierre/diffs': 1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@pierre/diffs': 1.3.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@scalar/astro': 0.4.16(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) '@scalar/openapi-parser': 0.28.16 '@scalar/openapi-types': 0.9.5 '@shikijs/transformers': 4.4.3 - '@shikijs/twoslash': 4.4.3(supports-color@10.2.2)(typescript@6.0.3) + '@shikijs/twoslash': 4.4.3(typescript@6.0.3) '@tailwindcss/typography': 0.5.20(tailwindcss@4.3.3) '@tailwindcss/vite': 4.3.3(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) '@types/mdast': 4.0.4 - '@vercel/analytics': 2.0.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) + '@vercel/analytics': 2.0.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) ai: 7.0.83(zod@4.4.3) astro: 7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0) babel-plugin-react-compiler: 1.0.0 @@ -18605,9 +18629,9 @@ snapshots: simple-icons: 13.21.0 string-width: 8.2.2 tailwindcss: 4.3.3 - takumi-js: 2.12.0(csstype@3.2.3)(react@19.2.8) + takumi-js: 2.12.0(react@19.2.8) tinyglobby: 0.2.17 - twoslash: 0.3.9(supports-color@10.2.2)(typescript@6.0.3) + twoslash: 0.3.9(typescript@6.0.3) typescript: 6.0.3 ufo: 1.6.4 undici: 8.10.0 @@ -20221,7 +20245,7 @@ snapshots: expr-eval-fork@3.0.3: {} - express-rate-limit@8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): + express-rate-limit@8.6.2(express@5.2.1): dependencies: debug: 4.4.3(supports-color@10.2.2) express: 5.2.1(supports-color@10.2.2) @@ -20831,7 +20855,7 @@ snapshots: '@ungap/structured-clone': 1.3.4 unist-util-position: 5.0.0 - hast-util-to-estree@3.1.3(supports-color@10.2.2): + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -20841,9 +20865,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) - mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) - mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -20866,7 +20890,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6(supports-color@10.2.2): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 @@ -20875,9 +20899,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) - mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) - mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -21800,7 +21824,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@10.2.2): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -21811,7 +21835,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@10.2.2): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -21828,17 +21852,17 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0(supports-color@10.2.2): + mdast-util-mdx@3.0.0: dependencies: mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) - mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) - mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) - mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@10.2.2): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -22432,6 +22456,33 @@ snapshots: - '@types/node' - babel-plugin-macros + next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 16.3.3 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.19 + caniuse-lite: 1.0.30001810 + postcss: 8.5.23 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.3 + '@next/swc-darwin-x64': 16.3.3 + '@next/swc-linux-arm64-gnu': 16.3.3 + '@next/swc-linux-arm64-musl': 16.3.3 + '@next/swc-linux-x64-gnu': 16.3.3 + '@next/swc-linux-x64-musl': 16.3.3 + '@next/swc-win32-arm64-msvc': 16.3.3 + '@next/swc-win32-x64-msvc': 16.3.3 + '@opentelemetry/api': 1.9.1 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.35.4(@types/node@26.4.0) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + next@16.3.3(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.3.3 @@ -22441,7 +22492,7 @@ snapshots: postcss: 8.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: '@next/swc-darwin-arm64': 16.3.3 '@next/swc-darwin-x64': 16.3.3 @@ -23454,11 +23505,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0(supports-color@10.2.2): + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 - hast-util-to-estree: 3.1.3(supports-color@10.2.2) + hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color @@ -23484,9 +23535,9 @@ snapshots: transitivePeerDependencies: - supports-color - remark-mdx@3.1.1(supports-color@10.2.2): + remark-mdx@3.1.1: dependencies: - mdast-util-mdx: 3.0.0(supports-color@10.2.2) + mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color @@ -24229,6 +24280,13 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7(supports-color@10.2.2) + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + optionalDependencies: + '@babel/core': 7.29.7 + stylehacks@7.0.11(postcss@8.5.26): dependencies: browserslist: 4.28.8 @@ -24429,11 +24487,11 @@ snapshots: tailwindcss@4.3.3: {} - takumi-js@2.12.0(csstype@3.2.3)(react@19.2.8): + takumi-js@2.12.0(react@19.2.8): dependencies: - '@takumi-rs/core': 2.12.0(csstype@3.2.3)(react@19.2.8) + '@takumi-rs/core': 2.12.0(react@19.2.8) '@takumi-rs/helpers': 2.12.0(react@19.2.8) - '@takumi-rs/wasm': 2.12.0(csstype@3.2.3)(react@19.2.8) + '@takumi-rs/wasm': 2.12.0(react@19.2.8) transitivePeerDependencies: - csstype - preact @@ -24640,9 +24698,9 @@ snapshots: twoslash-protocol@0.3.9: {} - twoslash@0.3.9(supports-color@10.2.2)(typescript@6.0.3): + twoslash@0.3.9(typescript@6.0.3): dependencies: - '@typescript/vfs': 1.6.4(supports-color@10.2.2)(typescript@6.0.3) + '@typescript/vfs': 1.6.4(typescript@6.0.3) twoslash-protocol: 0.3.9 typescript: 6.0.3 transitivePeerDependencies: @@ -25030,12 +25088,11 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.28(typescript@6.0.3) - volar-service-prettier@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.6): + volar-service-prettier@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): dependencies: vscode-uri: 3.2.0 optionalDependencies: '@volar/language-service': 2.4.28(typescript@6.0.3) - prettier: 3.9.6 volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3): dependencies: From bc5945babede8024fae6ac74be5563b7952f74eb Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 15:46:59 +0700 Subject: [PATCH 2/6] docs(effect): clarify createEffectClient works with server-side and client-side clients --- apps/content/docs/integrations/effect.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index ca840087e..4e081c523 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -209,7 +209,7 @@ All utilities support data-first `catchORPCError(program, handler)` and data-las ## Client Calls -`createEffectClient` wraps a [client](/docs/client/client-side) so every procedure returns a lazy Effect instead of a promise, ready to `yield*` inside Effect generators. The output becomes the success value, and errors land in the error channel with their original types preserved, ready for utilities like `catchORPCErrorCodes`: +`createEffectClient` wraps any oRPC client, whether [server-side](/docs/client/server-side) or [client-side](/docs/client/client-side), so every procedure returns a lazy Effect instead of a promise, ready to `yield*` inside Effect generators. The output becomes the success value, and errors land in the error channel with their original types preserved, ready for utilities like `catchORPCErrorCodes`: ```ts import { catchORPCErrorCodes, createEffectClient } from '@orpc/experimental-effect' From 02e9e6c27b2ce879b183204ae576c4ef223c784a Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 15:49:38 +0700 Subject: [PATCH 3/6] test(effect): cover RECURSIVE_CLIENT_UNWRAP_KEYS in createEffectClient --- packages/effect/src/client.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/effect/src/client.test.ts b/packages/effect/src/client.test.ts index 969969322..63a8dc92b 100644 --- a/packages/effect/src/client.test.ts +++ b/packages/effect/src/client.test.ts @@ -122,4 +122,18 @@ describe('createEffectClient', () => { expect((effectClient as any)[Symbol('test')]).toEqual(undefined) expect((effectClient.nested as any)[Symbol('test')]).toEqual(undefined) }) + + it('not recursive on unwrap keys', async () => { + const anyClient = effectClient as any + + expect(anyClient.then).toBeUndefined() + expect(await anyClient).toBe(effectClient) + expect(anyClient.bind).toBe(anyClient.bind) + expect(anyClient.valueOf).toBe(anyClient.valueOf) + expect(anyClient.toString).toBe(anyClient.toString) + expect(anyClient.toJSON).toBeUndefined() + + expect(anyClient.nested.then).toBeUndefined() + expect(await anyClient.nested).toBe(anyClient.nested) + }) }) From 2f62e6890e13b4a4e5d9e6e491765193bc024dd0 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 16:01:35 +0700 Subject: [PATCH 4/6] fix(effect): only treat failure types that can hold an ORPCError as hidden 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. --- packages/effect/src/error.test-d.ts | 44 +++++++++++++++++++++++++++-- packages/effect/src/error.ts | 24 ++++++++-------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/effect/src/error.test-d.ts b/packages/effect/src/error.test-d.ts index 7cf10dd98..65cd32dce 100644 --- a/packages/effect/src/error.test-d.ts +++ b/packages/effect/src/error.test-d.ts @@ -59,7 +59,7 @@ describe('catchORPCError', () => { expectTypeOf(recovered).toEqualTypeOf>() }) - it('treats every non-ORPCError failure type as possibly hiding an ORPCError', () => { + it('treats failure types that can hold an ORPCError as possibly hiding one', () => { const withTypeError = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | TypeError> void withTypeError.pipe(catchORPCError((error) => { @@ -67,11 +67,21 @@ describe('catchORPCError', () => { expectTypeOf(error.data).toEqualTypeOf() return Effect.succeed('recovered' as const) })) + }) - const withString = {} as Effect.Effect<'output', 'boom'> + it('does not treat failure types that cannot hold an ORPCError as hidden', () => { + const withString = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | 'boom'> void withString.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + return Effect.succeed('recovered' as const) + })) + + const onlyString = {} as Effect.Effect<'output', 'boom'> + + void onlyString.pipe(catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf() return Effect.succeed('recovered' as const) })) }) @@ -157,6 +167,19 @@ describe('catchORPCErrorCode', () => { return Effect.succeed('recovered' as const) })) }) + + it('keeps data typed and codes restricted when the channel cannot hold hidden ORPCErrors', () => { + const withString = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | 'boom'> + + void withString.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + return Effect.succeed('recovered' as const) + })) + + // @ts-expect-error - BAD_GATEWAY is not present and the channel cannot hold hidden ORPCErrors + void withString.pipe(catchORPCErrorCode('BAD_GATEWAY', () => Effect.succeed('recovered'))) + }) }) describe('catchORPCErrorCodes', () => { @@ -208,4 +231,19 @@ describe('catchORPCErrorCodes', () => { expectTypeOf(recovered).toEqualTypeOf>() }) + + it('keeps data typed and keys restricted when the channel cannot hold hidden ORPCErrors', () => { + const withString = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | 'boom'> + + void withString.pipe(catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + return Effect.succeed('recovered' as const) + }, + })) + + // @ts-expect-error - BAD_GATEWAY is not present and the channel cannot hold hidden ORPCErrors + void withString.pipe(catchORPCErrorCodes({ BAD_GATEWAY: () => Effect.succeed('recovered') })) + }) }) diff --git a/packages/effect/src/error.ts b/packages/effect/src/error.ts index 34cb779bf..320c49a28 100644 --- a/packages/effect/src/error.ts +++ b/packages/effect/src/error.ts @@ -25,11 +25,11 @@ import { Effect, Function } from 'effect' */ export const catchORPCError: { ( - f: (error: E extends AnyORPCError ? E : ORPCError) => Effect.Effect, + f: (error: E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, ): (self: Effect.Effect) => Effect.Effect, R | R2> ( self: Effect.Effect, - f: (error: E extends AnyORPCError ? E : ORPCError) => Effect.Effect, + f: (error: E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, ): Effect.Effect, R | R2> } = Function.dual( 2, @@ -66,14 +66,14 @@ export const catchORPCError: { * @see {@link https://orpc.dev/docs/integrations/effect#catching-orpcerrors | Effect Integration - Catching ORPCErrors} */ export const catchORPCErrorCode: { - ? TCode : ORPCErrorCode, E, A2, E2, R2>( + ? TCode : AnyORPCError extends E ? ORPCErrorCode : never, E, A2, E2, R2>( code: TCode, - f: (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect, + f: (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, ): (self: Effect.Effect) => Effect.Effect>, R | R2> - ? TCode : ORPCErrorCode, A2, E2, R2>( + ? TCode : AnyORPCError extends E ? ORPCErrorCode : never, A2, E2, R2>( self: Effect.Effect, code: TCode, - f: (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect, + f: (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, ): Effect.Effect>, R | R2> } = Function.dual( 3, @@ -117,12 +117,12 @@ export const catchORPCErrorCodes: { < E, Cases extends - & { [K in E extends ORPCError ? TCode : ORPCErrorCode]?: string extends K + & { [K in E extends ORPCError ? TCode : AnyORPCError extends E ? ORPCErrorCode : never]?: string extends K ? (error: never) => Effect.Effect : K extends infer C extends ORPCErrorCode - ? (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect + ? (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect : never } - & { [K in Exclude ? TCode : ORPCErrorCode>]: never }, + & { [K in Exclude ? TCode : AnyORPCError extends E ? ORPCErrorCode : never>]: never }, >( cases: Cases, ): (self: Effect.Effect) => Effect.Effect< @@ -136,12 +136,12 @@ export const catchORPCErrorCodes: { E, R, Cases extends - & { [K in E extends ORPCError ? TCode : ORPCErrorCode]?: string extends K + & { [K in E extends ORPCError ? TCode : AnyORPCError extends E ? ORPCErrorCode : never]?: string extends K ? (error: never) => Effect.Effect : K extends infer C extends ORPCErrorCode - ? (error: E extends AnyORPCError ? Extract> : ORPCError) => Effect.Effect + ? (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect : never } - & { [K in Exclude ? TCode : ORPCErrorCode>]: never }, + & { [K in Exclude ? TCode : AnyORPCError extends E ? ORPCErrorCode : never>]: never }, >( self: Effect.Effect, cases: Cases, From 2aa9d4a00799377e3a36c9418cd385c8ddc5f9d1 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 20:15:32 +0700 Subject: [PATCH 5/6] simplify --- apps/content/docs/integrations/effect.mdx | 2 -- packages/effect/src/client.ts | 10 ++++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index 4e081c523..9cd1c3a94 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -201,8 +201,6 @@ const handled = program.pipe( ) ``` -Failure types like `Error` can also hold `ORPCError` instances at runtime. When the error channel contains such types, these utilities accept any code and handlers also receive those hidden errors, so their `data` widens to `unknown`. On the client, combine with [isInferableError](/docs/client/error-handling#using-safe-and-isinferableerror) when you need fully typed data. - :::info All utilities support data-first `catchORPCError(program, handler)` and data-last `program.pipe(catchORPCError(handler))` styles. ::: diff --git a/packages/effect/src/client.ts b/packages/effect/src/client.ts index efde4f689..278ad233d 100644 --- a/packages/effect/src/client.ts +++ b/packages/effect/src/client.ts @@ -1,5 +1,4 @@ -import type { AnyNestedClient, Client, ClientContext, ClientOptions, ClientRest } from '@orpc/client' -import type { PromiseWithError } from '@orpc/shared' +import type { AnyNestedClient, Client, ClientContext, ClientRest } from '@orpc/client' import { RECURSIVE_CLIENT_UNWRAP_KEYS, resolveClientRest } from '@orpc/client' import { anyAbortSignal, isTypescriptObject } from '@orpc/shared' import { Effect } from 'effect' @@ -8,11 +7,10 @@ function callAsEffect, ...rest: ClientRest ): Effect.Effect { - const call = client as (input: TInput, options: ClientOptions) => PromiseWithError - const [input, options] = resolveClientRest(rest) + const [input, options] = resolveClientRest(rest) return Effect.tryPromise({ - try: signal => call(input, { + try: signal => client(input, { ...options, signal: anyAbortSignal([options.signal, signal]), }), @@ -38,7 +36,7 @@ export type EffectClient export function createEffectClient(client: T): EffectClient { const cache = new Map>() - const proxy = new Proxy((...rest: any[]) => callAsEffect(client as Client, ...rest as [unknown]), { + const proxy = new Proxy((...rest: any[]) => callAsEffect(client as Client, ...rest), { get(target, prop) { if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { return Reflect.get(target, prop) From 67268022341e10731944708763c010149e15bfd6 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 29 Aug 2026 09:45:23 +0700 Subject: [PATCH 6/6] Update index.test.ts --- packages/effect/src/index.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/effect/src/index.test.ts b/packages/effect/src/index.test.ts index 565f58ff1..aebdb2d3f 100644 --- a/packages/effect/src/index.test.ts +++ b/packages/effect/src/index.test.ts @@ -1,4 +1,4 @@ -it('exports EffectSchemaToJsonSchemaConverter, handlerGen, toStandardSchema, catchORPCError, catchORPCErrorCode, catchORPCErrorCodes', async () => { +it('exports', async () => { await expect(import('./index')).resolves.toMatchObject({ handlerGen: expect.any(Function), EffectSchemaToJsonSchemaConverter: expect.any(Function), @@ -6,5 +6,6 @@ it('exports EffectSchemaToJsonSchemaConverter, handlerGen, toStandardSchema, cat catchORPCError: expect.any(Function), catchORPCErrorCode: expect.any(Function), catchORPCErrorCodes: expect.any(Function), + createEffectClient: expect.any(Function), }) })