A type-safe integration between oRPC and Effect, enabling Effect-native procedures with full service injection support, OpenTelemetry tracing support and typesafe Effect errors support.
Inspired by effect-trpc.
- Effect-native procedures - Write oRPC procedures using generators with
yield*syntax - Type-safe service injection - Add base services with
.provide(layer)or pass aLayer/ManagedRuntime<R>directly - Tagged errors - Create Effect-native error classes with
ORPCTaggedErrorthat integrate with oRPC's error handling - Full oRPC compatibility - Mix Effect procedures with standard oRPC procedures in the same router
- Telemetry support with automatic tracing - Procedures are automatically traced with OpenTelemetry-compatible spans. Customize span names with
.traced(). - Builder pattern preserved - oRPC builder methods (
.errors(),.meta(),.route(),.input(),.output(),.use()) work seamlessly
npm install effect-orpc
# or
pnpm add effect-orpc
# or
bun add effect-orpcRunnable demos live in the repository's examples/ directory.
import { os } from "@orpc/server";
import { Effect, ManagedRuntime } from "effect";
import { eos, makeEffectORPC, ORPCTaggedError } from "effect-orpc";
interface User {
id: number;
name: string;
}
let users: User[] = [
{ id: 1, name: "John Doe" },
{ id: 2, name: "Jane Doe" },
{ id: 3, name: "James Dane" },
];
// Define your services
class UsersRepo extends Effect.Service<UsersRepo>()("UsersRepo", {
accessors: true,
sync: () => ({
get: (id: number) => users.find((u) => u.id === id),
}),
}) {}
// Special yieldable oRPC error class
class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
status: 404,
}) {}
// Create an Effect-aware oRPC builder with your service layer, context, errors,
// and middleware.
const effectProcedure = eos
.provide(UsersRepo.Default)
.errors({ UNAUTHORIZED: { status: 401 }, UserNotFoundError })
.$context<{ userId?: number }>()
.use(({ context, errors, next }) => {
if (context.userId === undefined) throw errors.UNAUTHORIZED();
return next({ context: { ...context, userId: context.userId } });
});
// Use ManagedRuntime only when scoped resources should be acquired once and
// released on shutdown, for example a shared cache, database pool, or telemetry SDK:
// const runtime = ManagedRuntime.make(UsersRepo.Default);
// const effectProcedure = makeEffectORPC(runtime).errors({ UserNotFoundError });
// Create the router with mixed procedures
export const router = {
health: os.handler(() => "ok"),
users: {
me: effectProcedure.effect(function* ({ context: { userId } }) {
const user = yield* UsersRepo.get(userId);
if (!user) {
return yield* new UserNotFoundError();
}
return user;
}),
},
};
export type Router = typeof router;The wrapper enforces that Effect procedures only use services provided by .provide(layer), request-scoped .provide(tag, provider) calls, or an initial Layer / ManagedRuntime. If you try to use a service that isn't available, you'll get a compile-time error:
import { Context, Effect, Layer } from "effect";
import { eos } from "effect-orpc";
class ProvidedService extends Context.Tag("ProvidedService")<
ProvidedService,
{ doSomething: () => Effect.Effect<string> }
>() {}
class MissingService extends Context.Tag("MissingService")<
MissingService,
{ doSomething: () => Effect.Effect<string> }
>() {}
const AppLive = Layer.succeed(ProvidedService, {
doSomething: () => Effect.succeed("ok"),
});
const effectProcedure = eos.provide(AppLive);
// ✅ This compiles - ProvidedService is provided by AppLive
const works = effectProcedure.effect(function* () {
const service = yield* ProvidedService;
return yield* service.doSomething();
});
// ❌ This fails to compile - MissingService is not provided
const fails = effectProcedure.effect(function* () {
const service = yield* MissingService; // Type error!
return yield* service.doSomething();
});ORPCTaggedError lets you create Effect-native error classes that integrate seamlessly with oRPC. These errors:
- Can be yielded in Effect generators (
yield* new MyError()oryield* Effect.fail(errors.MyError)) - Can be used in Effect builder's
.errors()maps for type-safe error handling alongside regular oRPC errors - Automatically convert to ORPCError when thrown
Make sure the tagged error class is passed to the effect .errors() to be able to yield the error class directly and make the client recognize it as defined.
const getUser = effectProcedure
// Mixed error maps
.errors({
// Regular oRPC error
NOT_FOUND: {
message: "User not found",
data: z.object({ id: z.string() }),
},
// Effect oRPC tagged error
UserNotFoundError,
// Note: The key of an oRPC error is not used as the error code
// So the following will only change the key of the error when accessing it
// from the errors object passed to the handler, but not the actual error code itself.
// To change the error's code, please see the next section on creating tagged errors.
USER_NOT_FOUND: UserNotFoundError,
// ^^^ same code as the `UserNotFoundError` error key, defined at the class level
})
.effect(function* ({ input, errors }) {
const user = yield* UsersRepo.findById(input.id);
if (!user) {
return yield* new UserNotFoundError();
// or return `yield* Effect.fail(errors.USER_NOT_FOUND())`
}
return user;
});import { ORPCTaggedError } from "effect-orpc";
// Basic tagged error - code defaults to 'USER_NOT_FOUND' (CONSTANT_CASE of tag)
class UserNotFound extends ORPCTaggedError("UserNotFound") {}
// With explicit code
class NotFound extends ORPCTaggedError("NotFound", { code: "NOT_FOUND" }) {}
// With default options (code defaults to 'VALIDATION_ERROR') (CONSTANT_CASE of tag)
class ValidationError extends ORPCTaggedError("ValidationError", {
status: 400,
message: "Validation failed",
}) {}
// With all options
class ForbiddenError extends ORPCTaggedError("ForbiddenError", {
code: "FORBIDDEN",
status: 403,
message: "Access denied",
schema: z.object({
reason: z.string(),
}),
}) {}
// With typed data using Standard Schema
class UserNotFoundWithData extends ORPCTaggedError("UserNotFoundWithData", {
schema: z.object({ userId: z.string() }),
}) {}All Effect procedures are automatically traced with Effect.withSpan. By default, the span name is the procedure path (e.g., users.getUser):
// Router structure determines span names automatically
const router = {
users: {
// Span name: "users.get"
get: effectProcedure.input(z.object({ id: z.string() })).effect(function* ({
input,
}) {
const userService = yield* UserService;
return yield* userService.findById(input.id);
}),
// Span name: "users.create"
create: effectProcedure
.input(z.object({ name: z.string() }))
.effect(function* ({ input }) {
const userService = yield* UserService;
return yield* userService.create(input.name);
}),
},
};Use .traced() to override the default span name:
const getUser = effectProcedure
.input(z.object({ id: z.string() }))
.traced("custom.span.name") // Override the default path-based name
.effect(function* ({ input }) {
const userService = yield* UserService;
return yield* userService.findById(input.id);
});To enable tracing, include the OpenTelemetry layer in your application layer:
import { NodeSdk } from "@effect/opentelemetry";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
const TracingLive = NodeSdk.layer(
Effect.sync(() => ({
resource: { serviceName: "my-service" },
spanProcessor: [new SimpleSpanProcessor(new OTLPTraceExporter())],
})),
);
const AppLive = Layer.mergeAll(UserServiceLive, TracingLive);
const effectProcedure = eos.provide(AppLive);When an Effect procedure fails, the span includes a properly formatted stack trace pointing to the definition site:
MyCustomError: Something went wrong
at <anonymous> (/app/src/procedures.ts:42:28)
at users.getById (/app/src/procedures.ts:41:35)
.use(...) accepts generator-based and Effect-returning middleware in addition
to native oRPC middleware. Two patterns are supported:
Gate — run auth or validation side effects, then let the pipeline continue
automatically (no need to call next):
effectProcedure.use(function* () {
const user = yield* CurrentUser;
yield* requireActiveUser(user);
});Wrap — call downstream explicitly and return the result. When porting oRPC
middleware that uses return next(...), use return yield* next(...):
effectProcedure.use(function* ({ next }) {
const user = yield* CurrentUser;
yield* requireActiveUser(user);
return yield* next({
context: { userId: user.id },
});
});To transform the downstream output, capture next() and pass through output:
effectProcedure.use(function* ({ next }, _input, output) {
const result = yield* next();
return yield* output(`${result.output}-wrapped`);
});Calling yield* next() without returning its result still runs the handler once, but prefer return yield* next(...) so the pipeline receives your middleware result explicitly.
Effect-returning middleware is also supported, including Effect.fn(...) and
() => Effect.gen(...):
effectProcedure.use(
Effect.fn("middleware.auth")(function* ({ next }) {
const user = yield* CurrentUser;
return yield* next({ context: { userId: user.id } });
}),
);Request-scoped providers support the same generator or Effect-returning style:
effectProcedure.provide(CurrentUser, function* ({ context }) {
yield* Effect.logDebug("resolving current user");
return context.user;
});effect-orpc batches contiguous Effect-native steps into one runtime boundary. Effect-native steps are .provide(...), .provideOptional(...), generator .use(function* ...), and .effect(function* ...). Effect-returning handlers, providers, and middleware are supported too; named Effect.fn(...) callbacks keep their own spans rather than being wrapped as generators.
eos
.provide(AppLive)
.provide(CurrentUser, function* ({ context }) {
return context.user;
})
.use(function* ({ next }) {
const user = yield* CurrentUser;
return yield* next({ context: { userId: user.id } });
})
.effect(function* ({ context }) {
const user = yield* CurrentUser;
return `${context.userId}:${user.id}`;
});The example above runs the provider, middleware, and handler inside a single Effect execution boundary.
A native oRPC middleware breaks the contiguous Effect pipeline. Pending Effect steps are flushed into one generated oRPC middleware before the native middleware:
eos
.provide(AppLive)
.provide(CurrentUser, getCurrentUser) // Effect group #1
.use(function* ({ next }) {
return yield* next();
})
.use(({ next }) => next()) // native oRPC middleware; flushes group #1
.use(function* ({ next }) {
return yield* next();
}) // Effect group #2
.effect(function* () {
return "ok";
});That split still creates multiple runtime boundaries. If the Node bridge is installed, however, effect-orpc carries the current FiberRefs through the native oRPC continuation and merges them into the next Effect boundary:
import "effect-orpc/node";Use the side-effect import when you only need continuity across internal effect-orpc boundaries, such as Effect group #1 → native oRPC middleware → Effect group #2.
Procedure-level .provide* after a native .handler(...) has no Effect handler boundary to attach to, so it is installed as an oRPC middleware that runs its provider Effect through the configured runtime source:
eos
.provide(AppLive)
.handler(() => "ok") // native oRPC handler
.provide(CurrentUser, getCurrentUser); // fallback provider middlewareIf you want .provide* and Effect middleware to batch with the handler, use .effect(function* ...) instead of .handler(...).
The /node entrypoint installs a bridge backed by AsyncLocalStorage. It has two uses:
import "effect-orpc/node"installs the bridge passively. This is enough foreffect-orpcto propagateFiberRefsacross its own split runtime boundaries.withFiberContext(() => next())actively seeds the bridge from an external Effect scope, such as framework middleware wrapping an oRPC handler.
Use withFiberContext when request-local FiberRef state is created outside the oRPC pipeline and should be visible inside handlers:
import { Hono } from "hono";
import { Effect } from "effect";
import { eos } from "effect-orpc";
import { withFiberContext } from "effect-orpc/node";
const effectProcedure = eos.provide(AppLive);
const app = new Hono();
app.use("*", async (c, next) => {
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.annotateLogsScoped({
requestId: c.get("requestId"),
});
yield* withFiberContext(() => next());
}),
);
});Importing withFiberContext from effect-orpc/node also installs the bridge, so you do not need a separate side-effect import.
When a captured fiber context and the application Layer / ManagedRuntime both provide the same service, effect-orpc prioritizes the captured context.
The application layer is treated as the base layer, while the bridge preserves more specific request-scoped values such as request IDs, logging annotations, tracing context, or scoped overrides when crossing runtime boundaries.
The main package stays runtime-agnostic; /node is separate because the bridge relies on AsyncLocalStorage from node:async_hooks.
Use implementEffect(contract, layerOrRuntime) when you already have an oRPC contract and want to keep contract-first enforcement while adding Effect-native handlers. Use eos.provide(layer) when you want to build procedures directly from the default Effect-aware builder.
import { Effect } from "effect";
import { eoc, implementEffect } from "effect-orpc";
import z from "zod";
class UsersRepo extends Effect.Service<UsersRepo>()("UsersRepo", {
accessors: true,
sync: () => ({
list: (amount: number) =>
Array.from({ length: amount }, (_, index) => `user-${index + 1}`),
}),
}) {}
const contract = {
users: {
list: eoc
.input(z.object({ amount: z.number().int().positive() }))
.output(z.array(z.string())),
},
};
const oe = implementEffect(contract, UsersRepo.Default);
export const router = oe.router({
users: {
list: oe.users.list.effect(function* ({ input }) {
return yield* UsersRepo.list(input.amount);
}),
},
});Contract leaves keep the contract-defined input, output, and error surface. They add .effect(...) alongside existing implementer methods such as .handler(...) and .use(...), but do not expose contract-changing builder methods like .input(...) or .output(...).
If your contract declares tagged Effect error classes, prefer eoc.errors(...) instead of raw oc.errors(...) so the error schema and metadata are derived directly from the ORPCTaggedError class.
The default Effect-aware procedure builder. Provide your application services with .provide(layer):
const effectProcedure = eos.provide(AppLive);Use makeEffectORPC(runtime) when a scoped Layer should be acquired once and released by your application shutdown path, such as a shared cache, database pool, HTTP client, or telemetry SDK:
const runtime = ManagedRuntime.make(AppLive);
const effectProcedure = makeEffectORPC(runtime);
// later, during app shutdown
await runtime.dispose();makeEffectORPC(builder) is also available when you need to wrap an existing oRPC builder:
const effectAuthedOs = makeEffectORPC(authedBuilder).provide(AppLive);Creates an Effect-aware contract implementer.
contract- An oRPC contract router built withoclayerOrRuntime- ALayer<R, E, never>provided per call, or a user-ownedManagedRuntime<R, E>when the application should control acquisition and release (for example, a shared cache, database pool, or telemetry SDK)
Returns a contract-shaped implementer tree whose leaves support .effect(...).
const oe = implementEffect(contract, AppLive);
const router = oe.router({
users: {
list: oe.users.list.effect(function* ({ input }) {
return yield* UsersRepo.list(input.amount);
}),
},
});An Effect-aware wrapper around oRPC's oc contract builder.
Use it when you want contract definitions to accept ORPCTaggedError classes directly in .errors(...) without duplicating the error schema.
class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
code: "NOT_FOUND",
schema: z.object({ userId: z.string() }),
}) {}
const contract = {
users: {
find: eoc
.errors({
NOT_FOUND: UserNotFoundError,
})
.input(z.object({ userId: z.string() }))
.output(z.object({ userId: z.string() })),
},
};Wraps an oRPC Builder with Effect support. Available methods:
| Method | Description |
|---|---|
.$config(config) |
Set or override the builder config |
.$context<U>() |
Set or override the initial context type |
.$meta(meta) |
Set or override the initial metadata |
.$route(route) |
Set or override the initial route configuration |
.$input(schema) |
Set or override the initial input schema |
.errors(map) |
Add type-safe custom errors |
.meta(meta) |
Set procedure metadata (merged with existing) |
.route(route) |
Configure OpenAPI route (merged with existing) |
.input(schema) |
Define input validation schema |
.output(schema) |
Define output validation schema |
.provide(layer) |
Provide a base Effect layer to downstream Effect middleware and handlers |
.provide(tag, provider) |
Provide a request-scoped Effect service to downstream Effect middleware and handlers |
.use(middleware) |
Add middleware |
.traced(name) |
Add a traceable span for telemetry (optional, defaults to the procedure's path) |
.handler(handler) |
Define a non-Effect handler (standard oRPC handler) |
.effect(handler) |
Define the Effect handler |
.prefix(prefix) |
Prefix all procedures in the router (for OpenAPI) |
.tag(...tags) |
Add tags to all procedures in the router (for OpenAPI) |
.router(router) |
Apply all options to a router |
.lazy(loader) |
Create and apply options to a lazy-loaded router |
The result of calling .effect(). Extends standard oRPC DecoratedProcedure with Effect type preservation.
| Method | Description |
|---|---|
.errors(map) |
Add more custom errors |
.meta(meta) |
Update metadata (merged with existing) |
.route(route) |
Update route configuration (merged) |
.provide(layer) |
Provide a base Effect layer |
.provide(tag, provider) |
Provide a request-scoped Effect service |
.use(middleware) |
Add middleware |
.callable(options?) |
Make procedure directly invocable |
.actionable(options?) |
Make procedure compatible with server actions |
Factory function to create Effect-native tagged error classes.
The options is an optional object containing:
schema?- Optional Standard Schema for the error's data payload (e.g.,z.object({ userId: z.string() }))code?- Optional ORPCErrorCode, defaults to CONSTANT_CASE of the tag (e.g.,UserNotFoundError→USER_NOT_FOUND_ERROR).status?- Sets the default status of the errormessage- Sets the default message of the error
MIT