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, Cause } from 'effect'

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

    if (Exit.isFailure(exit)) {
        console.error(Cause.pretty(exit.cause))
        throw Cause.squash(exit.cause)
    }

    return exit.value
}

Create Effect Service

Define a service with Context.Tag, then provide a live implementation with Layer.

service/pokeapi.ts

import { Context, Data, Effect, Layer } 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 Context.Tag("PokeApi")<
    PokeApi,
    {
        readonly getPokemon: (
            id: number,
        ) => Effect.Effect<Pokemon, FetchError | JsonError>
    }
>() {}

/** Live Implementation **/

export const PokeApiLive = Layer.succeed(PokeApi, {
    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(),
            })
        }),
})

Create oRPC procedure

Provide the layer with Effect.provide, then access the service via yield* PokeApi.

router.ts

import { base } from './base'
import { Effect } from 'effect'
import { PokeApi, PokeApiLive } 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* () {
                    const pokeApi = yield* PokeApi
                    return yield* pokeApi.getPokemon(input.id)
                }).pipe(
                    Effect.tapError((error) => Effect.logError(error.message)),
                    Effect.catchTags({
                        FetchError: () => Effect.die(errors.FETCH_ERROR()), // use Effect.die(e) to pass along the error causing an internal server error
                        JsonError: () => Effect.die(errors.JSON_ERROR()),
                    }),
                    Effect.provide(PokeApiLive)
                )
            )
        })
}

Composing Services

You can also compose services with layers. First define a Cache service:

service/cache.ts

import { Context, Data, Effect, Layer } from "effect"

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

export class Cache extends Context.Tag("Cache")<
    Cache,
    {
        readonly get: <T>(key: string) => Effect.Effect<T, CacheMissError>
        readonly set: (key: string, data: unknown, ttlMs?: number) => Effect.Effect<void>
    }
>() {}

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

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

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

Now compose it with PokeApi. 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 { Context, Effect, Layer } from "effect"
import { PokeApi, PokeApiLive, type Pokemon, FetchError, JsonError } from "./pokeapi"
import { Cache, CacheLive } from "./cache"

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

export class CachedPokeApi extends Context.Tag("CachedPokeApi")<
    CachedPokeApi,
    {
        readonly getPokemon: (
            id: number,
        ) => Effect.Effect<Pokemon, FetchError | JsonError>
    }
>() {}

/** Live Implementation — depends on PokeApi + Cache **/

export const CachedPokeApiLive = Layer.effect(
    CachedPokeApi,
    Effect.gen(function* () {
        const pokeApi = yield* PokeApi
        const cache = yield* Cache

        return {
            getPokemon: (id: number) =>
                cache.get<Pokemon>(`pokemon:${id}`).pipe(
                    // On cache miss, fetch from API and populate cache
                    Effect.catchTag("CacheMissError", () =>
                        pokeApi.getPokemon(id).pipe(
                            Effect.tap((pokemon) =>
                                cache.set(`pokemon:${id}`, pokemon, 60_000),
                            ),
                        ),
                    ),
                ),
        }
    }),
)

// Wire dependencies into a complete layer
export const MainLive = CachedPokeApiLive.pipe(
    Layer.provide(PokeApiLive),
    Layer.provide(CacheLive),
)

The router only handles FetchError and JsonError, with no knowledge of the cache.

router.ts

import { base } from './base'
import { Effect } from 'effect'
import { CachedPokeApi, MainLive } 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(
                Effect.gen(function* () {
                    const pokeApi = yield* CachedPokeApi
                    return yield* pokeApi.getPokemon(input.id)
                }).pipe(
                    Effect.catchTags({
                        FetchError: () => Effect.die(errors.FETCH_ERROR()),
                        JsonError: () => Effect.die(errors.JSON_ERROR()),
                    }),
                    Effect.provide(MainLive)
                )
            )
        })
}