Skip to content

Commit d9d031f

Browse files
authored
feat(effect): add catchORPCError, catchORPCErrorCode and catchORPCErrorCodes utils (#1766)
Adds `catchORPCError`, `catchORPCErrorCode` and `catchORPCErrorCodes` to `@orpc/experimental-effect` for recovering from `ORPCError` failures in an effect's error channel. Recovered errors are excluded from the resulting effect's error channel, and non-matching failures re-fail with their original cause. ## Details - All utilities support data-first and data-last (`.pipe`) styles, following Effect's `dual` convention. - Handlers receive the matching errors narrowed from the error channel, with per-code data types preserved. - `catchORPCErrorCode` and `catchORPCErrorCodes` mirror Effect's `catchTag`/`catchTags` naming and suggest/restrict codes to those present in the error channel. - Defects and interruptions are not caught. ## Testing - Logic tests cover all call styles, code matching/mismatch, partial and undefined handler maps, non-`ORPCError` passthrough, defects, successes, and re-failing handlers — 100% statement/branch/function/line coverage on the new module. - Type tests verify handler narrowing, error-channel exclusion, requirement merging, custom codes, and rejection of out-of-channel codes and map keys. - Docs: new "Catching ORPCErrors" section on the Effect integration page.
1 parent 240215c commit d9d031f

6 files changed

Lines changed: 535 additions & 1 deletion

File tree

apps/content/docs/integrations/effect.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,34 @@ if (isInferableError(error)) {
194194
}
195195
```
196196

197+
### Catching ORPCErrors
198+
199+
Use `catchORPCError` to recover from every `ORPCError` failure in the error channel of an effect, or `catchORPCErrorCode` and `catchORPCErrorCodes` to recover from specific codes only. Recovered errors are excluded from the resulting effect, and other failures re-fail with their original cause:
200+
201+
```ts
202+
import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from '@orpc/experimental-effect'
203+
import { Effect } from 'effect'
204+
205+
const recovered = program.pipe(
206+
catchORPCError(error => Effect.succeed(`caught ${error.code}`)),
207+
)
208+
209+
const fallback = program.pipe(
210+
catchORPCErrorCode('NOT_FOUND', error => Effect.succeed(error.data.id)),
211+
)
212+
213+
const handled = program.pipe(
214+
catchORPCErrorCodes({
215+
NOT_FOUND: error => Effect.succeed(error.data.id),
216+
CONFLICT: error => Effect.succeed(error.message),
217+
}),
218+
)
219+
```
220+
221+
::: info
222+
All utilities support data-first `catchORPCError(program, handler)` and data-last `program.pipe(catchORPCError(handler))` styles.
223+
:::
224+
197225
## Effect Schema
198226

199227
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/):
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { ORPCError } from '@orpc/server'
2+
import { Effect } from 'effect'
3+
import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error'
4+
5+
class Service1 {
6+
declare id: 'Service1'
7+
}
8+
9+
class Service2 {
10+
declare id: 'Service2'
11+
}
12+
13+
const effect = {} as Effect.Effect<
14+
'output',
15+
ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number> | TypeError,
16+
Service1
17+
>
18+
19+
describe('catchORPCError', () => {
20+
it('catches every ORPCError and excludes them from the error channel (data-last)', () => {
21+
const recovered = effect.pipe(catchORPCError((error) => {
22+
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>>()
23+
return Effect.succeed('recovered' as const)
24+
}))
25+
26+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'recovered', TypeError, Service1>>()
27+
})
28+
29+
it('catches every ORPCError and excludes them from the error channel (data-first)', () => {
30+
const recovered = catchORPCError(effect, (error) => {
31+
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>>()
32+
return Effect.succeed('recovered' as const)
33+
})
34+
35+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'recovered', TypeError, Service1>>()
36+
})
37+
38+
it('merges handler error and requirement channels into the result', () => {
39+
const recovered = effect.pipe(
40+
catchORPCError(() => ({} as Effect.Effect<'recovered', RangeError, Service2>)),
41+
)
42+
43+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'recovered', RangeError | TypeError, Service1 | Service2>>()
44+
})
45+
46+
it('handler receives never when the error channel contains no ORPCError', () => {
47+
const safe = {} as Effect.Effect<'output', TypeError>
48+
49+
void safe.pipe(catchORPCError((error) => {
50+
expectTypeOf(error).toEqualTypeOf<never>()
51+
return Effect.succeed('recovered' as const)
52+
}))
53+
})
54+
})
55+
56+
describe('catchORPCErrorCode', () => {
57+
it('catches ORPCErrors with a matching code and excludes them from the error channel (data-last)', () => {
58+
const recovered = effect.pipe(catchORPCErrorCode('NOT_FOUND', (error) => {
59+
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }>>()
60+
return Effect.succeed('recovered' as const)
61+
}))
62+
63+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'recovered', ORPCError<'CONFLICT', number> | TypeError, Service1>>()
64+
})
65+
66+
it('catches ORPCErrors with a matching code and excludes them from the error channel (data-first)', () => {
67+
const recovered = catchORPCErrorCode(effect, 'CONFLICT', (error) => {
68+
expectTypeOf(error).toEqualTypeOf<ORPCError<'CONFLICT', number>>()
69+
return Effect.succeed('recovered' as const)
70+
})
71+
72+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | TypeError, Service1>>()
73+
})
74+
75+
it('merges handler error and requirement channels into the result', () => {
76+
const recovered = effect.pipe(
77+
catchORPCErrorCode('NOT_FOUND', () => ({} as Effect.Effect<'recovered', RangeError, Service2>)),
78+
)
79+
80+
expectTypeOf(recovered).toEqualTypeOf<
81+
Effect.Effect<'output' | 'recovered', ORPCError<'CONFLICT', number> | RangeError | TypeError, Service1 | Service2>
82+
>()
83+
})
84+
85+
it('supports custom error codes', () => {
86+
const custom = {} as Effect.Effect<'output', ORPCError<'__CUSTOM__', undefined> | TypeError>
87+
88+
const recovered = custom.pipe(catchORPCErrorCode('__CUSTOM__', (error) => {
89+
expectTypeOf(error).toEqualTypeOf<ORPCError<'__CUSTOM__', undefined>>()
90+
return Effect.succeed('recovered' as const)
91+
}))
92+
93+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'recovered', TypeError, never>>()
94+
})
95+
96+
it('suggests and restricts the code to those present in the error channel', () => {
97+
// @ts-expect-error - BAD_GATEWAY is not present in the error channel (data-last)
98+
void effect.pipe(catchORPCErrorCode('BAD_GATEWAY', () => Effect.succeed('recovered')))
99+
100+
// @ts-expect-error - BAD_GATEWAY is not present in the error channel (data-first)
101+
void catchORPCErrorCode(effect, 'BAD_GATEWAY', () => Effect.succeed('recovered'))
102+
103+
// @ts-expect-error - code must be a string
104+
void effect.pipe(catchORPCErrorCode(123, () => Effect.succeed('recovered')))
105+
})
106+
})
107+
108+
describe('catchORPCErrorCodes', () => {
109+
it('narrows each handler and excludes handled codes from the error channel (data-last)', () => {
110+
const recovered = effect.pipe(catchORPCErrorCodes({
111+
NOT_FOUND: (error) => {
112+
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }>>()
113+
return Effect.succeed('nf' as const)
114+
},
115+
CONFLICT: (error) => {
116+
expectTypeOf(error).toEqualTypeOf<ORPCError<'CONFLICT', number>>()
117+
return {} as Effect.Effect<'cf', RangeError, Service2>
118+
},
119+
}))
120+
121+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'nf' | 'cf', RangeError | TypeError, Service1 | Service2>>()
122+
})
123+
124+
it('keeps unhandled codes in the error channel (data-first)', () => {
125+
const recovered = catchORPCErrorCodes(effect, {
126+
NOT_FOUND: (error) => {
127+
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }>>()
128+
return Effect.succeed('nf' as const)
129+
},
130+
})
131+
132+
expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'nf', ORPCError<'CONFLICT', number> | TypeError, Service1>>()
133+
})
134+
135+
it('suggests and restricts keys to the codes present in the error channel', () => {
136+
// @ts-expect-error - BAD_GATEWAY is not present in the error channel (data-last)
137+
void effect.pipe(catchORPCErrorCodes({ BAD_GATEWAY: () => Effect.succeed('recovered') }))
138+
139+
// @ts-expect-error - BAD_GATEWAY is not present in the error channel (data-first)
140+
void catchORPCErrorCodes(effect, { BAD_GATEWAY: () => Effect.succeed('recovered') })
141+
})
142+
})

packages/effect/src/error.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { ORPCError } from '@orpc/server'
2+
import { Cause, Effect, Exit } from 'effect'
3+
import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error'
4+
5+
describe('catchORPCError', () => {
6+
it('catches any ORPCError failure (data-last)', async () => {
7+
const handled = Effect.fail(new ORPCError('NOT_FOUND', { data: 'data' })).pipe(
8+
catchORPCError(error => Effect.succeed(`caught:${error.code}`)),
9+
)
10+
11+
await expect(Effect.runPromise(handled)).resolves.toEqual('caught:NOT_FOUND')
12+
})
13+
14+
it('catches any ORPCError failure (data-first)', async () => {
15+
const handled = catchORPCError(
16+
Effect.fail(new ORPCError('CONFLICT')),
17+
error => Effect.succeed(`caught:${error.code}`),
18+
)
19+
20+
await expect(Effect.runPromise(handled)).resolves.toEqual('caught:CONFLICT')
21+
})
22+
23+
it('does not catch non-ORPCError failures', async () => {
24+
const error = new Error('__TEST__')
25+
const handler = vi.fn(() => Effect.succeed('caught'))
26+
27+
const exit = await Effect.runPromiseExit(catchORPCError(Effect.fail(error), handler))
28+
29+
expect(exit).toEqual(Exit.fail(error))
30+
expect(handler).not.toHaveBeenCalled()
31+
})
32+
33+
it('does not catch defects', async () => {
34+
const error = new ORPCError('NOT_FOUND')
35+
const handler = vi.fn(() => Effect.succeed('caught'))
36+
37+
const exit = await Effect.runPromiseExit(catchORPCError(Effect.die(error), handler))
38+
39+
expect(exit).toEqual(Exit.failCause(Cause.die(error)))
40+
expect(handler).not.toHaveBeenCalled()
41+
})
42+
43+
it('does not touch successful effects', async () => {
44+
const handler = vi.fn(() => Effect.succeed('caught'))
45+
46+
await expect(Effect.runPromise(catchORPCError(Effect.succeed('output'), handler))).resolves.toEqual('output')
47+
expect(handler).not.toHaveBeenCalled()
48+
})
49+
50+
it('can fail again inside the handler', async () => {
51+
const error = new Error('__TEST__')
52+
53+
const exit = await Effect.runPromiseExit(
54+
Effect.fail(new ORPCError('NOT_FOUND')).pipe(
55+
catchORPCError(() => Effect.fail(error)),
56+
),
57+
)
58+
59+
expect(exit).toEqual(Exit.fail(error))
60+
})
61+
})
62+
63+
describe('catchORPCErrorCode', () => {
64+
it('catches ORPCError failures with a matching code (data-last)', async () => {
65+
const handled = Effect.fail(new ORPCError('NOT_FOUND', { data: 'data' })).pipe(
66+
catchORPCErrorCode('NOT_FOUND', error => Effect.succeed(`caught:${error.data}`)),
67+
)
68+
69+
await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data')
70+
})
71+
72+
it('catches ORPCError failures with a matching code (data-first)', async () => {
73+
const handled = catchORPCErrorCode(
74+
Effect.fail(new ORPCError('CONFLICT', { data: 'data' })),
75+
'CONFLICT',
76+
error => Effect.succeed(`caught:${error.data}`),
77+
)
78+
79+
await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data')
80+
})
81+
82+
it('does not catch ORPCError failures with a different code', async () => {
83+
const error = new ORPCError('CONFLICT') as ORPCError<'CONFLICT', undefined> | ORPCError<'NOT_FOUND', undefined>
84+
const handler = vi.fn(() => Effect.succeed('caught'))
85+
86+
const exit = await Effect.runPromiseExit(
87+
catchORPCErrorCode(Effect.fail(error), 'NOT_FOUND', handler),
88+
)
89+
90+
expect(exit).toEqual(Exit.fail(error))
91+
expect(handler).not.toHaveBeenCalled()
92+
})
93+
94+
it('does not catch non-ORPCError failures, even with a matching code property', async () => {
95+
const error = Object.assign(new Error('__TEST__'), { code: 'NOT_FOUND' }) as Error | ORPCError<'NOT_FOUND', undefined>
96+
const handler = vi.fn(() => Effect.succeed('caught'))
97+
98+
const exit = await Effect.runPromiseExit(
99+
catchORPCErrorCode(Effect.fail(error), 'NOT_FOUND', handler),
100+
)
101+
102+
expect(exit).toEqual(Exit.fail(error))
103+
expect(handler).not.toHaveBeenCalled()
104+
})
105+
106+
it('does not touch successful effects', async () => {
107+
const handler = vi.fn(() => Effect.succeed('caught'))
108+
const effect = Effect.succeed('output') as Effect.Effect<string, ORPCError<'NOT_FOUND', undefined>>
109+
110+
await expect(
111+
Effect.runPromise(catchORPCErrorCode(effect, 'NOT_FOUND', handler)),
112+
).resolves.toEqual('output')
113+
expect(handler).not.toHaveBeenCalled()
114+
})
115+
116+
it('can fail again inside the handler', async () => {
117+
const error = new Error('__TEST__')
118+
119+
const exit = await Effect.runPromiseExit(
120+
Effect.fail(new ORPCError('NOT_FOUND')).pipe(
121+
catchORPCErrorCode('NOT_FOUND', () => Effect.fail(error)),
122+
),
123+
)
124+
125+
expect(exit).toEqual(Exit.fail(error))
126+
})
127+
})
128+
129+
describe('catchORPCErrorCodes', () => {
130+
it('catches ORPCError failures with the matching handler (data-last)', async () => {
131+
const conflictHandler = vi.fn(() => Effect.succeed('caught:conflict'))
132+
const error = new ORPCError('NOT_FOUND', { data: 'data' }) as ORPCError<'NOT_FOUND', string> | ORPCError<'CONFLICT', undefined>
133+
134+
const handled = Effect.fail(error).pipe(
135+
catchORPCErrorCodes({
136+
NOT_FOUND: error => Effect.succeed(`caught:${error.data}`),
137+
CONFLICT: conflictHandler,
138+
}),
139+
)
140+
141+
await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data')
142+
expect(conflictHandler).not.toHaveBeenCalled()
143+
})
144+
145+
it('catches ORPCError failures with the matching handler (data-first)', async () => {
146+
const handled = catchORPCErrorCodes(
147+
Effect.fail(new ORPCError('CONFLICT', { data: 'data' })),
148+
{ CONFLICT: error => Effect.succeed(`caught:${error.data}`) },
149+
)
150+
151+
await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data')
152+
})
153+
154+
it('does not catch ORPCError failures without a matching handler', async () => {
155+
const error = new ORPCError('CONFLICT') as ORPCError<'CONFLICT', undefined> | ORPCError<'NOT_FOUND', undefined>
156+
const handler = vi.fn(() => Effect.succeed('caught'))
157+
158+
const exit = await Effect.runPromiseExit(
159+
catchORPCErrorCodes(Effect.fail(error), { NOT_FOUND: handler }),
160+
)
161+
162+
expect(exit).toEqual(Exit.fail(error))
163+
expect(handler).not.toHaveBeenCalled()
164+
})
165+
166+
it('does not catch ORPCError failures whose handler is undefined', async () => {
167+
const error = new ORPCError('NOT_FOUND')
168+
169+
const exit = await Effect.runPromiseExit(
170+
catchORPCErrorCodes(Effect.fail(error), { NOT_FOUND: undefined }),
171+
)
172+
173+
expect(exit).toEqual(Exit.fail(error))
174+
})
175+
176+
it('does not catch non-ORPCError failures, even with a matching code property', async () => {
177+
const error = Object.assign(new Error('__TEST__'), { code: 'NOT_FOUND' }) as Error | ORPCError<'NOT_FOUND', undefined>
178+
const handler = vi.fn(() => Effect.succeed('caught'))
179+
180+
const exit = await Effect.runPromiseExit(
181+
catchORPCErrorCodes(Effect.fail(error), { NOT_FOUND: handler }),
182+
)
183+
184+
expect(exit).toEqual(Exit.fail(error))
185+
expect(handler).not.toHaveBeenCalled()
186+
})
187+
188+
it('does not touch successful effects', async () => {
189+
const handler = vi.fn(() => Effect.succeed('caught'))
190+
const effect = Effect.succeed('output') as Effect.Effect<string, ORPCError<'NOT_FOUND', undefined>>
191+
192+
await expect(
193+
Effect.runPromise(catchORPCErrorCodes(effect, { NOT_FOUND: handler })),
194+
).resolves.toEqual('output')
195+
expect(handler).not.toHaveBeenCalled()
196+
})
197+
198+
it('can fail again inside a handler', async () => {
199+
const error = new Error('__TEST__')
200+
201+
const exit = await Effect.runPromiseExit(
202+
Effect.fail(new ORPCError('NOT_FOUND')).pipe(
203+
catchORPCErrorCodes({ NOT_FOUND: () => Effect.fail(error) }),
204+
),
205+
)
206+
207+
expect(exit).toEqual(Exit.fail(error))
208+
})
209+
})

0 commit comments

Comments
 (0)