Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 181 additions & 1 deletion packages/effect/src/error.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ORPCError, ORPCErrorCode } from '@orpc/server'
import type { AnyORPCError, ORPCErrorCode } from '@orpc/client'
import { ORPCError } from '@orpc/client'
import { Effect } from 'effect'
import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error'

Expand All @@ -22,6 +23,14 @@ const effectWithHidden = {} as Effect.Effect<
Service1
>

const effectWithAny = Effect.fail(new Error('boom') as unknown).pipe(
Effect.mapError(error => error instanceof ORPCError ? error : new ORPCError('INTERNAL_SERVER_ERROR')),
)

const effectWithWide = {} as Effect.Effect<'output', ORPCError<ORPCErrorCode, unknown>>
const effectWithString = {} as Effect.Effect<'output', ORPCError<string, unknown>>
const effectWithUnion = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }>>

describe('catchORPCError', () => {
it('catches every ORPCError and excludes them from the error channel (data-last)', () => {
const recovered = effect.pipe(catchORPCError((error) => {
Expand Down Expand Up @@ -180,6 +189,81 @@ describe('catchORPCErrorCode', () => {
// @ts-expect-error - BAD_GATEWAY is not present and the channel cannot hold hidden ORPCErrors
void withString.pipe(catchORPCErrorCode('BAD_GATEWAY', () => Effect.succeed('recovered')))
})

it('keeps AnyORPCError in the error channel and narrows the handler to the caught code', () => {
expectTypeOf(effectWithAny).toEqualTypeOf<Effect.Effect<never, AnyORPCError>>()

const recovered = effectWithAny.pipe(catchORPCErrorCode('NOT_FOUND', (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', any>>()
return Effect.succeed(error.code)
}))

expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'NOT_FOUND', AnyORPCError>>()
})

it('narrows wide codes to the caught code and keeps them in the error channel', () => {
const recoveredWide = effectWithWide.pipe(catchORPCErrorCode('NOT_FOUND', (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', unknown>>()
return Effect.succeed(error.code)
}))

expectTypeOf(recoveredWide).toEqualTypeOf<Effect.Effect<'output' | 'NOT_FOUND', ORPCError<ORPCErrorCode, unknown>>>()

const recoveredString = catchORPCErrorCode(effectWithString, 'NOT_FOUND', (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', unknown>>()
return Effect.succeed(error.code)
})

expectTypeOf(recoveredString).toEqualTypeOf<Effect.Effect<'output' | 'NOT_FOUND', ORPCError<string, unknown>>>()
})

it('narrows union codes to the caught code and keeps them in the error channel', () => {
const recovered = effectWithUnion.pipe(catchORPCErrorCode('NOT_FOUND', (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }>>()
return Effect.succeed(error.data.id)
}))

expectTypeOf(recovered).toEqualTypeOf<
Effect.Effect<'output' | string, ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }>>
>()
})

it('keeps errors in the error channel when the code is not a single literal', () => {
const code = {} as 'NOT_FOUND' | 'CONFLICT'

const recoveredUnion = effect.pipe(catchORPCErrorCode(code, (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>>()
return Effect.succeed('recovered' as const)
}))

expectTypeOf(recoveredUnion).toEqualTypeOf<
Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, Service1>
>()

const recoveredString = effectWithHidden.pipe(catchORPCErrorCode({} as string, (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }> | ORPCError<string, unknown>>()
return Effect.succeed('recovered' as const)
}))

expectTypeOf(recoveredString).toEqualTypeOf<
Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | Error, Service1>
>()
})

it('keeps ORPCError subclasses', () => {
class NotFoundError extends ORPCError<'NOT_FOUND', { id: string }> {
declare readonly extra: 'extra'
}

const withSubclass = {} as Effect.Effect<'output', NotFoundError | ORPCError<'CONFLICT', number>>

const recovered = withSubclass.pipe(catchORPCErrorCode('NOT_FOUND', (error) => {
expectTypeOf(error).toEqualTypeOf<NotFoundError>()
return Effect.succeed(error.extra)
}))

expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'output' | 'extra', ORPCError<'CONFLICT', number>>>()
})
})

describe('catchORPCErrorCodes', () => {
Expand Down Expand Up @@ -246,4 +330,100 @@ describe('catchORPCErrorCodes', () => {
// @ts-expect-error - BAD_GATEWAY is not present and the channel cannot hold hidden ORPCErrors
void withString.pipe(catchORPCErrorCodes({ BAD_GATEWAY: () => Effect.succeed('recovered') }))
})

it('keeps AnyORPCError in the error channel', () => {
const recovered = effectWithAny.pipe(catchORPCErrorCodes({
NOT_FOUND: (error) => {
expectTypeOf(error).toEqualTypeOf<AnyORPCError>()
return Effect.succeed('nf' as const)
},
}))

expectTypeOf(recovered).toEqualTypeOf<Effect.Effect<'nf', AnyORPCError>>()
})

it('types handlers of wide codes and keeps them in the error channel', () => {
const recoveredWide = effectWithWide.pipe(catchORPCErrorCodes({
NOT_FOUND: (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', unknown>>()
return Effect.succeed('nf' as const)
},
}))

expectTypeOf(recoveredWide).toEqualTypeOf<Effect.Effect<'output' | 'nf', ORPCError<ORPCErrorCode, unknown>>>()

const recoveredString = catchORPCErrorCodes(effectWithString, {
NOT_FOUND: (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<string, unknown>>()
return Effect.succeed('nf' as const)
},
})

expectTypeOf(recoveredString).toEqualTypeOf<Effect.Effect<'output' | 'nf', ORPCError<string, unknown>>>()
})

it('types handlers of undeclared keys when the channel can hold hidden ORPCErrors', () => {
void effectWithHidden.pipe(catchORPCErrorCodes({
__CUSTOM__: (error) => {
expectTypeOf(error).toExtend<AnyORPCError>()
expectTypeOf(error.code).toExtend<string>()
expectTypeOf(error.data).toEqualTypeOf<unknown>()
return Effect.succeed('recovered' as const)
},
}))
})

it('narrows union codes and excludes them only once every code is handled', () => {
const recoveredOne = effectWithUnion.pipe(catchORPCErrorCodes({
NOT_FOUND: (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'NOT_FOUND', { id: string }>>()
return Effect.succeed('nf' as const)
},
}))

expectTypeOf(recoveredOne).toEqualTypeOf<
Effect.Effect<'output' | 'nf', ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }>>
>()

const recoveredAll = effectWithUnion.pipe(catchORPCErrorCodes({
NOT_FOUND: () => Effect.succeed('nf' as const),
CONFLICT: (error) => {
expectTypeOf(error).toEqualTypeOf<ORPCError<'CONFLICT', { id: string }>>()
return Effect.succeed('cf' as const)
},
}))

expectTypeOf(recoveredAll).toEqualTypeOf<Effect.Effect<'output' | 'nf' | 'cf', never>>()
})

it('keeps codes whose handler can be undefined in the error channel', () => {
const recoveredUndefined = effect.pipe(catchORPCErrorCodes({
NOT_FOUND: undefined,
CONFLICT: () => Effect.succeed('cf' as const),
}))

expectTypeOf(recoveredUndefined).toEqualTypeOf<
Effect.Effect<'output' | 'cf', ORPCError<'NOT_FOUND', { id: string }>, Service1>
>()

const maybeHandler = {} as ((error: ORPCError<'NOT_FOUND', { id: string }>) => Effect.Effect<'nf', RangeError, Service2>) | undefined

const recoveredMaybe = effect.pipe(catchORPCErrorCodes({ NOT_FOUND: maybeHandler }))

expectTypeOf(recoveredMaybe).toEqualTypeOf<
Effect.Effect<
'output' | 'nf',
ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number> | RangeError,
Service1 | Service2
>
>()

const optionalCases = {} as { NOT_FOUND?: (error: ORPCError<'NOT_FOUND', { id: string }>) => Effect.Effect<'nf'> }

const recoveredOptional = effect.pipe(catchORPCErrorCodes(optionalCases))

expectTypeOf(recoveredOptional).toEqualTypeOf<
Effect.Effect<'output' | 'nf', ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, Service1>
>()
})
})
2 changes: 1 addition & 1 deletion packages/effect/src/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ORPCError } from '@orpc/server'
import { ORPCError } from '@orpc/client'
import { Cause, Effect, Exit } from 'effect'
import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error'

Expand Down
112 changes: 62 additions & 50 deletions packages/effect/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,50 @@
import type { AnyORPCError, ORPCErrorCode } from '@orpc/server'
import { ORPCError } from '@orpc/server'
import type { AnyORPCError, ORPCErrorCode } from '@orpc/client'
import type { Types } from 'effect'
import { ORPCError } from '@orpc/client'
import { getOwn } from '@orpc/shared'
import { Effect, Function } from 'effect'

type AnyEffect = Effect.Effect<any, any, any>

type ORPCErrorsOf<E> = E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError<ORPCErrorCode, unknown> : never

type ORPCErrorCodeOf<E> = ORPCErrorsOf<E>['code']

type ORPCErrorWithCode<E, TCode extends ORPCErrorCode> = E extends ORPCError<infer UCode, infer UData>
? [Exclude<UCode, TCode>] extends [never]
? E
: [UCode & TCode] extends [never] ? never : ORPCError<TCode extends UCode ? TCode : TCode & UCode, UData>
: AnyORPCError extends E ? ORPCError<TCode, unknown> : never

type ExcludeORPCErrorCode<E, TCode extends string> = E extends ORPCError<infer UCode, any>
? [Exclude<UCode, TCode>] extends [never] ? never : E
: E

type LiteralORPCErrorCode<TCode extends string> = Types.IsUnion<TCode> extends true
? never
: Record<never, never> extends Record<TCode, unknown> ? never : TCode

type ORPCErrorCodeCases<E, TCases> = Types.NoExcessProperties<{
[K in ORPCErrorCodeOf<E>]?: Record<never, never> extends Record<K, unknown>
? { bivarianceHack(error: ORPCErrorWithCode<E, K>): AnyEffect }['bivarianceHack']
: (error: ORPCErrorWithCode<E, K>) => AnyEffect
}, TCases>

/**
* The codes `TCases` surely handles: required keys with a function value.
*/
type HandledORPCErrorCodes<TCases> = {
[K in keyof TCases]-?: TCases[K] extends (...args: any[]) => any ? K : never
}[Types.RequiredKeys<TCases>] & string

type ORPCErrorCodeCasesEffect<TCases> = ReturnType<Extract<TCases[keyof TCases], (...args: any[]) => any>>

type CatchORPCErrorCodesEffect<A, E, R, TCases> = Effect.Effect<
A | Effect.Success<ORPCErrorCodeCasesEffect<TCases>>,
ExcludeORPCErrorCode<E, HandledORPCErrorCodes<TCases>> | Effect.Error<ORPCErrorCodeCasesEffect<TCases>>,
R | Effect.Services<ORPCErrorCodeCasesEffect<TCases>>
>

/**
* Recovers from `ORPCError` failures in the error channel of an effect.
* Other failures re-fail with their original cause. Because failure types
Expand All @@ -25,17 +67,17 @@ import { Effect, Function } from 'effect'
*/
export const catchORPCError: {
<E, A2, E2, R2>(
f: (error: E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError<ORPCErrorCode, unknown> : never) => Effect.Effect<A2, E2, R2>,
f: (error: ORPCErrorsOf<E>) => Effect.Effect<A2, E2, R2>,
): <A, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | A2, E2 | Exclude<E, AnyORPCError>, R | R2>
<A, E, R, A2, E2, R2>(
self: Effect.Effect<A, E, R>,
f: (error: E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError<ORPCErrorCode, unknown> : never) => Effect.Effect<A2, E2, R2>,
f: (error: ORPCErrorsOf<E>) => Effect.Effect<A2, E2, R2>,
): Effect.Effect<A | A2, E2 | Exclude<E, AnyORPCError>, R | R2>
} = Function.dual(
2,
(
self: Effect.Effect<any, any, any>,
f: (error: AnyORPCError) => Effect.Effect<any, any, any>,
self: AnyEffect,
f: (error: AnyORPCError) => AnyEffect,
) => self.pipe(
Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError, f),
),
Expand Down Expand Up @@ -66,21 +108,21 @@ export const catchORPCError: {
* @see {@link https://orpc.dev/docs/integrations/effect#catching-orpcerrors | Effect Integration - Catching ORPCErrors}
*/
export const catchORPCErrorCode: {
<const TCode extends E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never, E, A2, E2, R2>(
<const TCode extends ORPCErrorCodeOf<E>, E, A2, E2, R2>(
code: TCode,
f: (error: E extends AnyORPCError ? Extract<E, ORPCError<TCode, any>> : AnyORPCError extends E ? ORPCError<TCode, unknown> : never) => Effect.Effect<A2, E2, R2>,
): <A, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | A2, E2 | Exclude<E, ORPCError<TCode, any>>, R | R2>
<A, E, R, const TCode extends E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never, A2, E2, R2>(
f: (error: ORPCErrorWithCode<E, TCode>) => Effect.Effect<A2, E2, R2>,
): <A, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | A2, E2 | ExcludeORPCErrorCode<E, LiteralORPCErrorCode<TCode>>, R | R2>
<A, E, R, const TCode extends ORPCErrorCodeOf<E>, A2, E2, R2>(
self: Effect.Effect<A, E, R>,
code: TCode,
f: (error: E extends AnyORPCError ? Extract<E, ORPCError<TCode, any>> : AnyORPCError extends E ? ORPCError<TCode, unknown> : never) => Effect.Effect<A2, E2, R2>,
): Effect.Effect<A | A2, E2 | Exclude<E, ORPCError<TCode, any>>, R | R2>
f: (error: ORPCErrorWithCode<E, TCode>) => Effect.Effect<A2, E2, R2>,
): Effect.Effect<A | A2, E2 | ExcludeORPCErrorCode<E, LiteralORPCErrorCode<TCode>>, R | R2>
} = Function.dual(
3,
(
self: Effect.Effect<any, any, any>,
self: AnyEffect,
code: ORPCErrorCode,
f: (error: AnyORPCError) => Effect.Effect<any, any, any>,
f: (error: AnyORPCError) => AnyEffect,
) => self.pipe(
Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError && error.code === code, f),
),
Expand Down Expand Up @@ -114,48 +156,18 @@ export const catchORPCErrorCode: {
* @see {@link https://orpc.dev/docs/integrations/effect#catching-orpcerrors | Effect Integration - Catching ORPCErrors}
*/
export const catchORPCErrorCodes: {
<
E,
Cases extends
& { [K in E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never]?: string extends K
? (error: never) => Effect.Effect<any, any, any>
: K extends infer C extends ORPCErrorCode
? (error: E extends AnyORPCError ? Extract<E, ORPCError<C, any>> : AnyORPCError extends E ? ORPCError<C, unknown> : never) => Effect.Effect<any, any, any>
: never }
& { [K in Exclude<keyof Cases, E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never>]: never },
>(
<E, Cases extends ORPCErrorCodeCases<E, Cases>>(
cases: Cases,
): <A, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<
A | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect<infer A2, any, any> ? A2 : never }[keyof Cases],
| Exclude<E, ORPCError<keyof Cases & ORPCErrorCode, any>>
| { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect<any, infer E2, any> ? E2 : never }[keyof Cases],
R | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect<any, any, infer R2> ? R2 : never }[keyof Cases]
>
<
A,
E,
R,
Cases extends
& { [K in E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never]?: string extends K
? (error: never) => Effect.Effect<any, any, any>
: K extends infer C extends ORPCErrorCode
? (error: E extends AnyORPCError ? Extract<E, ORPCError<C, any>> : AnyORPCError extends E ? ORPCError<C, unknown> : never) => Effect.Effect<any, any, any>
: never }
& { [K in Exclude<keyof Cases, E extends ORPCError<infer TCode, any> ? TCode : AnyORPCError extends E ? ORPCErrorCode : never>]: never },
>(
): <A, R>(self: Effect.Effect<A, E, R>) => CatchORPCErrorCodesEffect<A, E, R, Cases>
<A, E, R, Cases extends ORPCErrorCodeCases<E, Cases>>(
self: Effect.Effect<A, E, R>,
cases: Cases,
): Effect.Effect<
A | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect<infer A2, any, any> ? A2 : never }[keyof Cases],
| Exclude<E, ORPCError<keyof Cases & ORPCErrorCode, any>>
| { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect<any, infer E2, any> ? E2 : never }[keyof Cases],
R | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect<any, any, infer R2> ? R2 : never }[keyof Cases]
>
): CatchORPCErrorCodesEffect<A, E, R, Cases>
} = Function.dual(
2,
(
self: Effect.Effect<any, any, any>,
cases: Record<string, ((error: AnyORPCError) => Effect.Effect<any, any, any>) | undefined>,
self: AnyEffect,
cases: Record<string, ((error: AnyORPCError) => AnyEffect) | undefined>,
) => self.pipe(
Effect.catchIf(
(error): error is AnyORPCError => error instanceof ORPCError && typeof getOwn(cases, error.code) === 'function',
Expand Down
Loading