Skip to content

utopyin/effect-orpc

Repository files navigation

effect-orpc

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.

Features

  • Effect-native procedures - Write oRPC procedures using generators with yield* syntax
  • Type-safe service injection - Add base services with .provide(layer) or pass a Layer / ManagedRuntime<R> directly
  • Tagged errors - Create Effect-native error classes with ORPCTaggedError that 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

Installation

npm install effect-orpc
# or
pnpm add effect-orpc
# or
bun add effect-orpc

Runnable demos live in the repository's examples/ directory.

Demo

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;

Type Safety

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

Error Handling

ORPCTaggedError lets you create Effect-native error classes that integrate seamlessly with oRPC. These errors:

  • Can be yielded in Effect generators (yield* new MyError() or yield* 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;
  });

Creating Tagged Errors

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() }),
}) {}

Traceable Spans

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

Enabling OpenTelemetry

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

Error Stack Traces

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)

Effect middleware

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

Runtime boundaries and fiber context continuity

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 middleware

If you want .provide* and Effect middleware to batch with the handler, use .effect(function* ...) instead of .handler(...).

Request-Scoped Fiber Context

The /node entrypoint installs a bridge backed by AsyncLocalStorage. It has two uses:

  • import "effect-orpc/node" installs the bridge passively. This is enough for effect-orpc to propagate FiberRefs across 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.

Contract-First Usage

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.

API Reference

eos

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

implementEffect(contract, layerOrRuntime)

Creates an Effect-aware contract implementer.

  • contract - An oRPC contract router built with oc
  • layerOrRuntime - A Layer<R, E, never> provided per call, or a user-owned ManagedRuntime<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);
    }),
  },
});

eoc

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() })),
  },
};

EffectBuilder

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

EffectDecoratedProcedure

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

ORPCTaggedError(tag, options?)

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., UserNotFoundErrorUSER_NOT_FOUND_ERROR).
  • status? - Sets the default status of the error
  • message - Sets the default message of the error

License

MIT

About

Effect-TS integration for oRPC

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors