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)
)
)
})
}