diff --git a/packages/effect/src/error.test-d.ts b/packages/effect/src/error.test-d.ts index 65cd32dce7..05c96d8680 100644 --- a/packages/effect/src/error.test-d.ts +++ b/packages/effect/src/error.test-d.ts @@ -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' @@ -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> +const effectWithString = {} as Effect.Effect<'output', ORPCError> +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) => { @@ -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>() + + const recovered = effectWithAny.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed(error.code) + })) + + expectTypeOf(recovered).toEqualTypeOf>() + }) + + 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>() + return Effect.succeed(error.code) + })) + + expectTypeOf(recoveredWide).toEqualTypeOf>>() + + const recoveredString = catchORPCErrorCode(effectWithString, 'NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed(error.code) + }) + + expectTypeOf(recoveredString).toEqualTypeOf>>() + }) + + 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>() + 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<'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>() + 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() + return Effect.succeed(error.extra) + })) + + expectTypeOf(recovered).toEqualTypeOf>>() + }) }) describe('catchORPCErrorCodes', () => { @@ -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() + return Effect.succeed('nf' as const) + }, + })) + + expectTypeOf(recovered).toEqualTypeOf>() + }) + + it('types handlers of wide codes and keeps them in the error channel', () => { + const recoveredWide = effectWithWide.pipe(catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('nf' as const) + }, + })) + + expectTypeOf(recoveredWide).toEqualTypeOf>>() + + const recoveredString = catchORPCErrorCodes(effectWithString, { + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('nf' as const) + }, + }) + + expectTypeOf(recoveredString).toEqualTypeOf>>() + }) + + it('types handlers of undeclared keys when the channel can hold hidden ORPCErrors', () => { + void effectWithHidden.pipe(catchORPCErrorCodes({ + __CUSTOM__: (error) => { + expectTypeOf(error).toExtend() + expectTypeOf(error.code).toExtend() + expectTypeOf(error.data).toEqualTypeOf() + 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>() + 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>() + return Effect.succeed('cf' as const) + }, + })) + + expectTypeOf(recoveredAll).toEqualTypeOf>() + }) + + 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> + >() + }) }) diff --git a/packages/effect/src/error.test.ts b/packages/effect/src/error.test.ts index fe6addf3bf..8b3f6ea415 100644 --- a/packages/effect/src/error.test.ts +++ b/packages/effect/src/error.test.ts @@ -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' diff --git a/packages/effect/src/error.ts b/packages/effect/src/error.ts index 320c49a28b..989d03ae09 100644 --- a/packages/effect/src/error.ts +++ b/packages/effect/src/error.ts @@ -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 + +type ORPCErrorsOf = E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError : never + +type ORPCErrorCodeOf = ORPCErrorsOf['code'] + +type ORPCErrorWithCode = E extends ORPCError + ? [Exclude] extends [never] + ? E + : [UCode & TCode] extends [never] ? never : ORPCError + : AnyORPCError extends E ? ORPCError : never + +type ExcludeORPCErrorCode = E extends ORPCError + ? [Exclude] extends [never] ? never : E + : E + +type LiteralORPCErrorCode = Types.IsUnion extends true + ? never + : Record extends Record ? never : TCode + +type ORPCErrorCodeCases = Types.NoExcessProperties<{ + [K in ORPCErrorCodeOf]?: Record extends Record + ? { bivarianceHack(error: ORPCErrorWithCode): AnyEffect }['bivarianceHack'] + : (error: ORPCErrorWithCode) => AnyEffect +}, TCases> + +/** + * The codes `TCases` surely handles: required keys with a function value. + */ +type HandledORPCErrorCodes = { + [K in keyof TCases]-?: TCases[K] extends (...args: any[]) => any ? K : never +}[Types.RequiredKeys] & string + +type ORPCErrorCodeCasesEffect = ReturnType any>> + +type CatchORPCErrorCodesEffect = Effect.Effect< + A | Effect.Success>, + ExcludeORPCErrorCode> | Effect.Error>, + R | Effect.Services> +> + /** * Recovers from `ORPCError` failures in the error channel of an effect. * Other failures re-fail with their original cause. Because failure types @@ -25,17 +67,17 @@ import { Effect, Function } from 'effect' */ export const catchORPCError: { ( - f: (error: E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, + f: (error: ORPCErrorsOf) => Effect.Effect, ): (self: Effect.Effect) => Effect.Effect, R | R2> ( self: Effect.Effect, - f: (error: E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, + f: (error: ORPCErrorsOf) => Effect.Effect, ): Effect.Effect, R | R2> } = Function.dual( 2, ( - self: Effect.Effect, - f: (error: AnyORPCError) => Effect.Effect, + self: AnyEffect, + f: (error: AnyORPCError) => AnyEffect, ) => self.pipe( Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError, f), ), @@ -66,21 +108,21 @@ export const catchORPCError: { * @see {@link https://orpc.dev/docs/integrations/effect#catching-orpcerrors | Effect Integration - Catching ORPCErrors} */ export const catchORPCErrorCode: { - ? TCode : AnyORPCError extends E ? ORPCErrorCode : never, E, A2, E2, R2>( + , E, A2, E2, R2>( code: TCode, - f: (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, - ): (self: Effect.Effect) => Effect.Effect>, R | R2> - ? TCode : AnyORPCError extends E ? ORPCErrorCode : never, A2, E2, R2>( + f: (error: ORPCErrorWithCode) => Effect.Effect, + ): (self: Effect.Effect) => Effect.Effect>, R | R2> + , A2, E2, R2>( self: Effect.Effect, code: TCode, - f: (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect, - ): Effect.Effect>, R | R2> + f: (error: ORPCErrorWithCode) => Effect.Effect, + ): Effect.Effect>, R | R2> } = Function.dual( 3, ( - self: Effect.Effect, + self: AnyEffect, code: ORPCErrorCode, - f: (error: AnyORPCError) => Effect.Effect, + f: (error: AnyORPCError) => AnyEffect, ) => self.pipe( Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError && error.code === code, f), ), @@ -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 ? TCode : AnyORPCError extends E ? ORPCErrorCode : never]?: string extends K - ? (error: never) => Effect.Effect - : K extends infer C extends ORPCErrorCode - ? (error: E extends AnyORPCError ? Extract> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect - : never } - & { [K in Exclude ? TCode : AnyORPCError extends E ? ORPCErrorCode : never>]: never }, - >( + >( cases: Cases, - ): (self: Effect.Effect) => Effect.Effect< - A | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect ? A2 : never }[keyof Cases], - | Exclude> - | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect ? E2 : never }[keyof Cases], - R | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect ? R2 : never }[keyof Cases] - > - < - A, - E, - R, - Cases extends - & { [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> : AnyORPCError extends E ? ORPCError : never) => Effect.Effect - : never } - & { [K in Exclude ? TCode : AnyORPCError extends E ? ORPCErrorCode : never>]: never }, - >( + ): (self: Effect.Effect) => CatchORPCErrorCodesEffect + >( self: Effect.Effect, cases: Cases, - ): Effect.Effect< - A | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect ? A2 : never }[keyof Cases], - | Exclude> - | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect ? E2 : never }[keyof Cases], - R | { [K in keyof Cases]: Cases[K] extends (...args: any[]) => Effect.Effect ? R2 : never }[keyof Cases] - > + ): CatchORPCErrorCodesEffect } = Function.dual( 2, ( - self: Effect.Effect, - cases: Record Effect.Effect) | undefined>, + self: AnyEffect, + cases: Record AnyEffect) | undefined>, ) => self.pipe( Effect.catchIf( (error): error is AnyORPCError => error instanceof ORPCError && typeof getOwn(cases, error.code) === 'function',