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
40 changes: 39 additions & 1 deletion apps/content/docs/integrations/effect.mdx
Original file line number Diff line number Diff line change
@@ -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"
---
Expand Down Expand Up @@ -205,6 +205,44 @@ const handled = program.pipe(
All utilities support data-first `catchORPCError(program, handler)` and data-last `program.pipe(catchORPCError(handler))` styles.
:::

## Client Calls

`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`:
Comment thread
dinwwwh marked this conversation as resolved.

```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/):
Expand Down
1 change: 1 addition & 0 deletions packages/effect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"effect": ">=4.0.0-beta.90"
},
"dependencies": {
"@orpc/client": "workspace:*",
"@orpc/contract": "workspace:*",
"@orpc/json-schema": "workspace:*",
"@orpc/server": "workspace:*",
Expand Down
81 changes: 81 additions & 0 deletions packages/effect/src/client.test-d.ts
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>>()
})
})
139 changes: 139 additions & 0 deletions packages/effect/src/client.test.ts
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)
})
})
63 changes: 63 additions & 0 deletions packages/effect/src/client.ts
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>
}
Loading
Loading