Table of Contents

How to use oRPC with Effect

A basic example on how to use oRPC with effect

Don't know how to use effect? Read through this Beginners Guide

Install effect

pnpm i effect -D

Create helper to handle errors

helper.ts

import { Effect, Exit } from 'effect'

export async function runEffect<T>(effect: Effect.Effect<T, never>): Promise<T> {
    const exit = await Effect.runPromiseExit(effect)

    if (Exit.isFailure(exit)) {
        const cause = exit.cause
        if (cause._tag === 'Die') {
            throw cause.defect
        }
        if (cause._tag === 'Fail') {
            throw cause.error
        }
        throw cause
    }

    return exit.value
}

Create Effect Service

service/pokeapi.ts

import { Effect, Data } from "effect"

// Simple Pokemon type (just the basics)
export interface Pokemon {
    id: number
    name: string
    height: number
    weight: number
    types: Array<{
        type: {
            name: string
        }
    }>
    sprites: {
        front_default: string | null
    }
}

/** Errors **/

export class FetchError extends Data.TaggedError("FetchError")<{}> { }
export class JsonError extends Data.TaggedError("JsonError")<{}> { }

/** Service Definition **/
export class PokeApi extends Effect.Service<PokeApi>()("PokeApi", {
    sync: () => ({
        getPokemon: (id: number) =>
            Effect.gen(function* () {
                const response = yield* Effect.tryPromise({
                    try: () => fetch(`https://pokeapi.co/api/v2/pokemon/${id}/`),
                    catch: () => new FetchError(),
                })

                if (!response.ok) {
                    return yield* Effect.fail(new FetchError())
                }

                return yield* Effect.tryPromise({
                    try: () => response.json() as Promise<Pokemon>,
                    catch: () => new JsonError(),
                })
            }),
    }),
    accessors: true,
}) {}

Create oRPC procedure

router.ts

import { base } from './base'
import { Effect } from 'effect'
import { PokeApi } from './service/pokeapi.ts'
import { z } from 'zod'
import { runEffect } from "./helper.ts"

export const router = {
    getPokemon: base
        .input(z.object({ id: z.number() }))
        .handler(async ({ input, errors }) => {
            return await runEffect(
                Effect.gen(function* () {
                    return yield* PokeApi.getPokemon(input.id)
                }).pipe(
                    Effect.catchTags({
                        FetchError: () => Effect.die(errors.FETCH_ERROR()),
                        JsonError: () => Effect.die(errors.JSON_ERROR()),
                    }),
                    Effect.provide(PokeApi.Default)
                )
            )
        })
}

Composing Services

You can also compose services without exposing the internal errors to the consumer.

service/cache.ts

import { Effect, Data } from "effect"

export class CacheMissError extends Data.TaggedError("CacheMissError")<{}> {}

const cache = new Map<string, { data: unknown; expiresAt: number }>()

export const cacheService = {
    get: <T>(key: string): Effect.Effect<T, CacheMissError> =>
        Effect.suspend(() => {
            const entry = cache.get(key)
            if (!entry || Date.now() > entry.expiresAt) {
                cache.delete(key)
                return Effect.fail(new CacheMissError())
            }
            return Effect.succeed(entry.data as T)
        }),

    set: (key: string, data: unknown, ttlMs = 30_000) =>
        Effect.sync(() => { cache.set(key, { data, expiresAt: Date.now() + ttlMs }) }),
}

Now compose it with pokeApi. The CacheMissError is caught internally with Effect.catchTag — it never leaks to the consumer. The interface only exposes the same FetchError | JsonError as the original.

service/cached-pokeapi.ts

import { Effect } from "effect"
import { PokeApi, type Pokemon, type FetchError, type JsonError } from "./pokeapi"
import { cacheService } from "./cache"

/** Service Definition — only PokeApi errors are exposed **/

interface CachedPokeApi {
    readonly getPokemon: (id: number) => Effect.Effect<Pokemon, FetchError | JsonError>
}

/** Implementation **/

export const cachedPokeApi: CachedPokeApi = {
    getPokemon: (id) =>
        cacheService.get<Pokemon>(`pokemon:${id}`).pipe(
            // On cache miss, fetch from API and populate cache
            Effect.catchTag("CacheMissError", () =>
                PokeApi.getPokemon(id).pipe(
                    Effect.tap((pokemon) => cacheService.set(`pokemon:${id}`, pokemon, 60_000)),
                    Effect.provide(PokeApi.Default)
                )
            )
        ),
}

The router is unchanged — it only handles FetchError and JsonError, with no knowledge of the cache.

router.ts

import { base } from './base'
import { Effect } from 'effect'
import { cachedPokeApi } from './service/cached-pokeapi'
import { z } from 'zod'
import { runEffect } from './helper'

export const router = {
    getPokemon: base
        .input(z.object({ id: z.number() }))
        .handler(async ({ input, errors }) => {
            return await runEffect(
                cachedPokeApi.getPokemon(input.id).pipe(
                    Effect.catchTags({
                        FetchError: () => Effect.die(errors.FETCH_ERROR()),
                        JsonError: () => Effect.die(errors.JSON_ERROR()),
                    })
                )
            )
        })
}