-
-
Notifications
You must be signed in to change notification settings - Fork 165
feat(effect): add createEffectClient and hidden ORPCError-aware catch utilities #1965
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
dinwwwh
merged 6 commits into
middleapi:main
from
dinwwwh:claude/effect-safe-client-util-b4d912
Aug 29, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
fa1df6d
feat(effect): add createEffectClient and hidden ORPCError-aware catch…
dinwwwh bc5945b
docs(effect): clarify createEffectClient works with server-side and c…
dinwwwh 02e9e6c
test(effect): cover RECURSIVE_CLIENT_UNWRAP_KEYS in createEffectClient
dinwwwh 2f62e68
fix(effect): only treat failure types that can hold an ORPCError as h…
dinwwwh 2aa9d4a
simplify
dinwwwh 6726802
Update index.test.ts
dinwwwh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ClientContext, string, number, Error | ORPCError<'BAD_GATEWAY', { val: string }> | ORPCError<'NOT_FOUND', { id: number }>> | ||
| optional: Client<ClientContext, string | undefined, number, Error> | ||
| 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<number, Error | ORPCError<'BAD_GATEWAY', { val: string }> | 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<EffectClient<typeof client>>() | ||
| }) | ||
|
|
||
| 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<unknown>() | ||
|
|
||
| return Effect.succeed('recovered' as const) | ||
| }, | ||
| }), | ||
| ) | ||
|
|
||
| expectTypeOf(recovered).toEqualTypeOf< | ||
| Effect.Effect<number | 'recovered', Error | ORPCError<'NOT_FOUND', { id: number }>> | ||
| >() | ||
| }) | ||
|
|
||
| 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<Effect.Effect<number | 'recovered', Error>>() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| 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<ClientContext, string, number, Error | ORPCError<'NOT_FOUND', string>> | ||
| nested: { | ||
| pong: Client<ClientContext, { id: number }, { result: string }, Error> | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| }) | ||
|
|
||
| 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) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| 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' | ||
|
|
||
| function callAsEffect<TClientContext extends ClientContext, TInput, TOutput, TError>( | ||
| client: Client<TClientContext, TInput, TOutput, TError>, | ||
| ...rest: ClientRest<TClientContext, TInput> | ||
| ): Effect.Effect<TOutput, TError> { | ||
| const [input, options] = resolveClientRest(rest) | ||
|
|
||
| return Effect.tryPromise({ | ||
| try: signal => client(input, { | ||
| ...options, | ||
| signal: anyAbortSignal([options.signal, signal]), | ||
| }), | ||
| catch: error => error as TError, | ||
| }) | ||
| } | ||
|
|
||
| export type EffectClient<T extends AnyNestedClient> | ||
| = T extends Client<infer UContext, infer UInput, infer UOutput, infer UError> | ||
| ? (...rest: ClientRest<UContext, UInput>) => Effect.Effect<UOutput, UError> | ||
| : { | ||
| [K in keyof T]: T[K] extends AnyNestedClient ? EffectClient<T[K]> : 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<T extends AnyNestedClient>(client: T): EffectClient<T> { | ||
| const cache = new Map<string, EffectClient<AnyNestedClient>>() | ||
|
|
||
| const proxy = new Proxy((...rest: any[]) => callAsEffect(client as Client<ClientContext, unknown, unknown, unknown>, ...rest), { | ||
| 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<string, unknown>)[prop] | ||
|
|
||
| if (!isTypescriptObject(value)) { | ||
| return value | ||
| } | ||
|
|
||
| effectClient = createEffectClient(value as AnyNestedClient) | ||
| cache.set(prop, effectClient) | ||
| } | ||
|
|
||
| return effectClient | ||
| }, | ||
| }) | ||
|
|
||
| return proxy as EffectClient<T> | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.