An open API service indexing awesome lists of open source software.

https://github.com/utopyin/effect-orpc

Effect-TS integration for oRPC
https://github.com/utopyin/effect-orpc

effect effect-ts orpc

Last synced: 4 days ago
JSON representation

Effect-TS integration for oRPC

Awesome Lists containing this project

README

          

# effect-orpc

A type-safe integration between [oRPC](https://orpc.dev/) and [Effect](https://effect.website/), enabling Effect-native procedures with full service injection support, OpenTelemetry tracing support and typesafe Effect errors support.

Inspired by [effect-trpc](https://github.com/mikearnaldi/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` 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

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

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

## Demo

```ts
import { os } from "@orpc/server";
import { Effect, ManagedRuntime } from "effect";
import { 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" },
];

// Authenticated os with initial context & errors set
const authedOs = os
.errors({ UNAUTHORIZED: { status: 401 } })
.$context<{ userId?: number }>()
.use(({ context, errors, next }) => {
if (context.userId === undefined) throw errors.UNAUTHORIZED();
return next({ context: { ...context, userId: context.userId } });
});

// Define your services
class UsersRepo extends Effect.Service()("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, optionally from
// another base oRPC builder, and provide tagged errors.
const effectOs = makeEffectORPC(UsersRepo.Default, authedOs).errors({
UserNotFoundError,
});

// You can also pass an explicit ManagedRuntime if you need lifecycle control:
// const runtime = ManagedRuntime.make(UsersRepo.Default);
// const effectOs = makeEffectORPC(runtime, authedOs).errors({ UserNotFoundError });

// Or start with only the builder and provide the layer later:
// const effectOs = makeEffectORPC(authedOs)
// .provide(UsersRepo.Default)
// .errors({ UserNotFoundError });

// Create the router with mixed procedures
export const router = {
health: os.handler(() => "ok"),
users: {
me: effectOs.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:

```ts
import { Context, Effect, Layer } from "effect";
import { makeEffectORPC } from "effect-orpc";

class ProvidedService extends Context.Tag("ProvidedService")<
ProvidedService,
{ doSomething: () => Effect.Effect }
>() {}

class MissingService extends Context.Tag("MissingService")<
MissingService,
{ doSomething: () => Effect.Effect }
>() {}

const AppLive = Layer.succeed(ProvidedService, {
doSomething: () => Effect.succeed("ok"),
});

const effectOs = makeEffectORPC(AppLive);

// ✅ This compiles - ProvidedService is provided by AppLive
const works = effectOs.effect(function* () {
const service = yield* ProvidedService;
return yield* service.doSomething();
});

// ❌ This fails to compile - MissingService is not provided
const fails = effectOs.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.

```ts
const getUser = effectOs
// 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

```ts
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`):

```ts
// Router structure determines span names automatically
const router = {
users: {
// Span name: "users.get"
get: effectOs.input(z.object({ id: z.string() })).effect(function* ({
input,
}) {
const userService = yield* UserService;
return yield* userService.findById(input.id);
}),
// Span name: "users.create"
create: effectOs.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:

```ts
const getUser = effectOs
.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:

```ts
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 effectOs = makeEffectORPC(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 (/app/src/procedures.ts:42:28)
at users.getById (/app/src/procedures.ts:41:35)
```

## Effect middleware

`.use(...)` accepts generator-based Effect 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`):

```ts
effectOs.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(...)`:

```ts
effectOs.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`:

```ts
effectOs.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.

### 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* ...)`.

```ts
makeEffectORPC(AppLive)
.provide(CurrentUser, ({ context }) => Effect.succeed(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
internal `runtime.runPromiseExit(...)` call.

A native oRPC middleware breaks the contiguous Effect pipeline. Pending Effect
steps are flushed into one generated oRPC middleware before the native middleware:

```ts
makeEffectORPC(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:

```ts
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 runtime:

```ts
makeEffectORPC(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:

```ts
import { Hono } from "hono";
import { Effect } from "effect";
import { makeEffectORPC } from "effect-orpc";
import { withFiberContext } from "effect-orpc/node";

const effectOs = makeEffectORPC(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 `makeEffectORPC(layerOrRuntime, builder?)` when you want to build
procedures directly from an oRPC builder.

```ts
import { Effect } from "effect";
import { eoc, implementEffect } from "effect-orpc";
import z from "zod";

class UsersRepo extends Effect.Service()("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

### `makeEffectORPC(layerOrRuntime, builder?)`

Creates an Effect-aware procedure builder. The recommended default is to pass
your application `Layer` up front.

Returns an `EffectBuilder` instance.

```ts
// With default builder
const effectOs = makeEffectORPC(AppLive);

// With customized builder
const effectAuthedOs = makeEffectORPC(AppLive, authedBuilder);
```

You can also start from a builder and provide the layer later, or pass a
`ManagedRuntime` when you need explicit runtime lifecycle control:

```ts
const effectOsWithProvidedLayer = makeEffectORPC().provide(AppLive);
const effectAuthedOsWithProvidedLayer =
makeEffectORPC(authedBuilder).provide(AppLive);
const effectOsFromRuntime = makeEffectORPC(runtime);
```

### `implementEffect(contract, layerOrRuntime)`

Creates an Effect-aware contract implementer.

- `contract` - An oRPC contract router built with `oc`
- `layerOrRuntime` - A `Layer` or `ManagedRuntime` that provides services for Effect procedures

Returns a contract-shaped implementer tree whose leaves support `.effect(...)`.

```ts
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.

```ts
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()` | 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, fn)` | 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, fn)` | 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., `UserNotFoundError` → `USER_NOT_FOUND_ERROR`).
- `status?` - Sets the default status of the error
- `message` - Sets the default message of the error

## License

MIT