effect-orpc 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -537
- package/dist/index.js +274 -49
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/effect-builder.ts +367 -178
- package/src/effect-enhance-router.ts +114 -0
- package/src/effect-procedure.ts +95 -32
- package/src/index.ts +20 -7
- package/src/tagged-error.ts +154 -100
- package/src/tests/effect-builder.test.ts +52 -39
- package/src/tests/effect-error-map.test.ts +36 -31
- package/src/tests/effect-procedure.test.ts +12 -12
- package/src/tests/tagged-error.test.ts +84 -27
- package/src/types/index.ts +422 -0
- package/src/types/variants.ts +1327 -0
package/README.md
CHANGED
|
@@ -10,9 +10,8 @@ Inspired by [effect-trpc](https://github.com/mikearnaldi/effect-trpc).
|
|
|
10
10
|
- **Type-safe service injection** - Use `ManagedRuntime<R>` to provide services to procedures with compile-time safety
|
|
11
11
|
- **Tagged errors** - Create Effect-native error classes with `ORPCTaggedError` that integrate with oRPC's error handling
|
|
12
12
|
- **Full oRPC compatibility** - Mix Effect procedures with standard oRPC procedures in the same router
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **Server actions support** - Full compatibility with framework server actions
|
|
13
|
+
- **Telemetry support with automatic tracing** - Procedures are automatically traced with OpenTelemetry-compatible spans. Customize span names with `.traced()`.
|
|
14
|
+
- **Builder pattern preserved** - oRPC builder methods (`.errors()`, `.meta()`, `.route()`, `.input()`, `.output()`, `.use()`) work seamlessly
|
|
16
15
|
|
|
17
16
|
## Installation
|
|
18
17
|
|
|
@@ -24,71 +23,68 @@ pnpm add effect-orpc
|
|
|
24
23
|
bun add effect-orpc
|
|
25
24
|
```
|
|
26
25
|
|
|
27
|
-
##
|
|
26
|
+
## Demo of the features
|
|
28
27
|
|
|
29
28
|
```ts
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import { z } from 'zod'
|
|
29
|
+
import { os } from "@orpc/server";
|
|
30
|
+
import { Effect, ManagedRuntime } from "effect";
|
|
31
|
+
import { makeEffectORPC, ORPCTaggedError } from "effect-orpc";
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
findById: (id: string) => Effect.Effect<User | undefined>
|
|
40
|
-
findAll: () => Effect.Effect<User[]>
|
|
41
|
-
create: (name: string) => Effect.Effect<User>
|
|
42
|
-
}
|
|
43
|
-
>() {}
|
|
44
|
-
|
|
45
|
-
// Create service implementation
|
|
46
|
-
const UserServiceLive = Layer.succeed(UserService, {
|
|
47
|
-
findById: id => Effect.succeed(users.find(u => u.id === id)),
|
|
48
|
-
findAll: () => Effect.succeed(users),
|
|
49
|
-
create: name => Effect.succeed({ id: crypto.randomUUID(), name })
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
// Create runtime with your services
|
|
53
|
-
const runtime = ManagedRuntime.make(UserServiceLive)
|
|
54
|
-
|
|
55
|
-
// Create Effect-aware oRPC builder
|
|
56
|
-
const effectOs = makeEffectORPC(runtime)
|
|
57
|
-
|
|
58
|
-
// Define your procedures
|
|
59
|
-
const getUser = effectOs
|
|
60
|
-
.input(z.object({ id: z.string() }))
|
|
61
|
-
.effect(function* ({ input }) {
|
|
62
|
-
const userService = yield* UserService
|
|
63
|
-
return yield* userService.findById(input.id)
|
|
64
|
-
})
|
|
33
|
+
interface User {
|
|
34
|
+
id: number;
|
|
35
|
+
name: string;
|
|
36
|
+
}
|
|
65
37
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
38
|
+
let users: User[] = [
|
|
39
|
+
{ id: 1, name: "John Doe" },
|
|
40
|
+
{ id: 2, name: "Jane Doe" },
|
|
41
|
+
{ id: 3, name: "James Dane" },
|
|
42
|
+
];
|
|
70
43
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const router = os.router({
|
|
80
|
-
// Standard oRPC procedure
|
|
81
|
-
health: os.handler(() => 'ok'),
|
|
44
|
+
// Authenticated os with initial context & errors set
|
|
45
|
+
const authedOs = os
|
|
46
|
+
.errors({ UNAUTHORIZED: { status: 401 } })
|
|
47
|
+
.$context<{ userId?: number }>()
|
|
48
|
+
.use(({ context, errors, next }) => {
|
|
49
|
+
if (context.userId === undefined) throw errors.UNAUTHORIZED();
|
|
50
|
+
return next({ context: { ...context, userId: context.userId } });
|
|
51
|
+
});
|
|
82
52
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
})
|
|
89
|
-
})
|
|
53
|
+
// Define your services
|
|
54
|
+
class UsersRepo extends Effect.Service<UsersRepo>()("UserService", {
|
|
55
|
+
accessors: true,
|
|
56
|
+
sync: () => ({
|
|
57
|
+
get: (id: number) => users.find((u) => u.id === id),
|
|
58
|
+
}),
|
|
59
|
+
}) {}
|
|
60
|
+
|
|
61
|
+
// Special yieldable oRPC error class
|
|
62
|
+
class UserNotFoundError extends ORPCTaggedError()("UserNotFoundError", {
|
|
63
|
+
status: 404,
|
|
64
|
+
}) {}
|
|
90
65
|
|
|
91
|
-
|
|
66
|
+
// Create runtime with your services
|
|
67
|
+
const runtime = ManagedRuntime.make(UsersRepo.Default);
|
|
68
|
+
// Create Effect-aware oRPC builder from an other (optional) base oRPC builder
|
|
69
|
+
const effectOs = makeEffectORPC(runtime, authedOs).errors({
|
|
70
|
+
UserNotFoundError,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Create the router with mixed procedures
|
|
74
|
+
export const router = {
|
|
75
|
+
health: os.handler(() => "ok"),
|
|
76
|
+
users: {
|
|
77
|
+
me: effectOs.effect(function* ({ context: { userId } }) {
|
|
78
|
+
const user = yield* UsersRepo.get(userId);
|
|
79
|
+
if (!user) {
|
|
80
|
+
return yield* new UserNotFoundError();
|
|
81
|
+
}
|
|
82
|
+
return user;
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type Router = typeof router;
|
|
92
88
|
```
|
|
93
89
|
|
|
94
90
|
## Type Safety
|
|
@@ -96,460 +92,131 @@ export type Router = typeof router
|
|
|
96
92
|
The wrapper enforces that Effect procedures only use services provided by the `ManagedRuntime`. If you try to use a service that isn't in the runtime, you'll get a compile-time error:
|
|
97
93
|
|
|
98
94
|
```ts
|
|
99
|
-
|
|
95
|
+
import { Context, Effect, Layer, ManagedRuntime } from "effect";
|
|
96
|
+
import { makeEffectORPC } from "effect-orpc";
|
|
97
|
+
|
|
98
|
+
class ProvidedService extends Context.Tag("ProvidedService")<
|
|
100
99
|
ProvidedService,
|
|
101
100
|
{ doSomething: () => Effect.Effect<string> }
|
|
102
101
|
>() {}
|
|
103
102
|
|
|
104
|
-
class MissingService extends Context.Tag(
|
|
103
|
+
class MissingService extends Context.Tag("MissingService")<
|
|
105
104
|
MissingService,
|
|
106
105
|
{ doSomething: () => Effect.Effect<string> }
|
|
107
106
|
>() {}
|
|
108
107
|
|
|
109
|
-
const runtime = ManagedRuntime.make(
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
const runtime = ManagedRuntime.make(
|
|
109
|
+
Layer.succeed(ProvidedService, {
|
|
110
|
+
doSomething: () => Effect.succeed("ok"),
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
112
113
|
|
|
113
|
-
const effectOs = makeEffectORPC(runtime)
|
|
114
|
+
const effectOs = makeEffectORPC(runtime);
|
|
114
115
|
|
|
115
116
|
// ✅ This compiles - ProvidedService is in the runtime
|
|
116
117
|
const works = effectOs.effect(function* () {
|
|
117
|
-
const
|
|
118
|
-
return yield*
|
|
119
|
-
})
|
|
118
|
+
const service = yield* ProvidedService;
|
|
119
|
+
return yield* service.doSomething();
|
|
120
|
+
});
|
|
120
121
|
|
|
121
122
|
// ❌ This fails to compile - MissingService is not in the runtime
|
|
122
123
|
const fails = effectOs.effect(function* () {
|
|
123
|
-
const
|
|
124
|
-
return yield*
|
|
125
|
-
})
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
## Using Services
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
import { makeEffectORPC } from 'effect-orpc'
|
|
132
|
-
import { Context, Effect, Layer, ManagedRuntime } from 'effect'
|
|
133
|
-
import { z } from 'zod'
|
|
134
|
-
|
|
135
|
-
// Define services
|
|
136
|
-
class DatabaseService extends Context.Tag('DatabaseService')<
|
|
137
|
-
DatabaseService,
|
|
138
|
-
{
|
|
139
|
-
query: <T>(sql: string) => Effect.Effect<T[]>
|
|
140
|
-
execute: (sql: string) => Effect.Effect<void>
|
|
141
|
-
}
|
|
142
|
-
>() {}
|
|
143
|
-
|
|
144
|
-
class CacheService extends Context.Tag('CacheService')<
|
|
145
|
-
CacheService,
|
|
146
|
-
{
|
|
147
|
-
get: <T>(key: string) => Effect.Effect<T | undefined>
|
|
148
|
-
set: <T>(key: string, value: T, ttl?: number) => Effect.Effect<void>
|
|
149
|
-
}
|
|
150
|
-
>() {}
|
|
151
|
-
|
|
152
|
-
// Create layers
|
|
153
|
-
const DatabaseServiceLive = Layer.succeed(DatabaseService, {
|
|
154
|
-
query: sql => Effect.succeed([]),
|
|
155
|
-
execute: sql => Effect.succeed(undefined),
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
const CacheServiceLive = Layer.succeed(CacheService, {
|
|
159
|
-
get: key => Effect.succeed(undefined),
|
|
160
|
-
set: (key, value, ttl) => Effect.succeed(undefined),
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
// Compose layers
|
|
164
|
-
const AppLive = Layer.mergeAll(DatabaseServiceLive, CacheServiceLive)
|
|
165
|
-
|
|
166
|
-
// Create runtime with all services
|
|
167
|
-
const runtime = ManagedRuntime.make(AppLive)
|
|
168
|
-
const effectOs = makeEffectORPC(runtime)
|
|
169
|
-
|
|
170
|
-
// Use multiple services in a procedure
|
|
171
|
-
const getUserWithCache = effectOs
|
|
172
|
-
.input(z.object({ id: z.string() }))
|
|
173
|
-
.effect(function* ({ input }) {
|
|
174
|
-
const cache = yield* CacheService
|
|
175
|
-
const db = yield* DatabaseService
|
|
176
|
-
|
|
177
|
-
// Try cache first
|
|
178
|
-
const cached = yield* cache.get<User>(`user:${input.id}`)
|
|
179
|
-
if (cached)
|
|
180
|
-
return cached
|
|
181
|
-
|
|
182
|
-
// Fall back to database
|
|
183
|
-
const [user] = yield* db.query<User>(`SELECT * FROM users WHERE id = '${input.id}'`)
|
|
184
|
-
if (user) {
|
|
185
|
-
yield* cache.set(`user:${input.id}`, user, 3600)
|
|
186
|
-
}
|
|
187
|
-
return user
|
|
188
|
-
})
|
|
124
|
+
const service = yield* MissingService; // Type error!
|
|
125
|
+
return yield* service.doSomething();
|
|
126
|
+
});
|
|
189
127
|
```
|
|
190
128
|
|
|
191
|
-
##
|
|
192
|
-
|
|
193
|
-
You can pass a customized oRPC builder as the second argument to inherit middleware, errors, and configuration:
|
|
194
|
-
|
|
195
|
-
```ts
|
|
196
|
-
import { makeEffectORPC } from 'effect-orpc'
|
|
197
|
-
import { ORPCError, os } from '@orpc/server'
|
|
198
|
-
import { Effect } from 'effect'
|
|
199
|
-
|
|
200
|
-
// Create a customized base builder with auth middleware
|
|
201
|
-
const authedOs = os
|
|
202
|
-
.errors({
|
|
203
|
-
UNAUTHORIZED: { message: 'Not authenticated' },
|
|
204
|
-
FORBIDDEN: { message: 'Access denied' },
|
|
205
|
-
})
|
|
206
|
-
.use(async ({ context, next, errors }) => {
|
|
207
|
-
if (!context.user) {
|
|
208
|
-
throw errors.UNAUTHORIZED()
|
|
209
|
-
}
|
|
210
|
-
return next({ context: { ...context, userId: context.user.id } })
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
// Wrap the customized builder with Effect support
|
|
214
|
-
const effectAuthedOs = makeEffectORPC(runtime, authedOs)
|
|
215
|
-
|
|
216
|
-
// All procedures inherit the auth middleware and error definitions
|
|
217
|
-
const getProfile = effectAuthedOs.effect(function* ({ context }) {
|
|
218
|
-
const userService = yield* UserService
|
|
219
|
-
return yield* userService.findById(context.userId)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
const updateProfile = effectAuthedOs
|
|
223
|
-
.input(z.object({ name: z.string() }))
|
|
224
|
-
.effect(function* ({ context, input }) {
|
|
225
|
-
const userService = yield* UserService
|
|
226
|
-
return yield* userService.update(context.userId, input)
|
|
227
|
-
})
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
## Chaining Builder Methods
|
|
231
|
-
|
|
232
|
-
The `EffectBuilder` supports all standard oRPC builder methods:
|
|
233
|
-
|
|
234
|
-
```ts
|
|
235
|
-
const createPost = effectOs
|
|
236
|
-
// Add custom errors
|
|
237
|
-
.errors({
|
|
238
|
-
NOT_FOUND: { message: 'User not found' },
|
|
239
|
-
VALIDATION_ERROR: {
|
|
240
|
-
message: 'Invalid input',
|
|
241
|
-
data: z.object({ field: z.string(), issue: z.string() })
|
|
242
|
-
},
|
|
243
|
-
})
|
|
244
|
-
// Add metadata
|
|
245
|
-
.meta({ auth: true, rateLimit: 100 })
|
|
246
|
-
// Configure route for OpenAPI
|
|
247
|
-
.route({ method: 'POST', path: '/posts', tags: ['posts'] })
|
|
248
|
-
// Define input schema
|
|
249
|
-
.input(z.object({
|
|
250
|
-
title: z.string().min(1).max(200),
|
|
251
|
-
content: z.string(),
|
|
252
|
-
authorId: z.string(),
|
|
253
|
-
}))
|
|
254
|
-
// Define output schema
|
|
255
|
-
.output(z.object({
|
|
256
|
-
id: z.string(),
|
|
257
|
-
title: z.string(),
|
|
258
|
-
content: z.string(),
|
|
259
|
-
createdAt: z.date(),
|
|
260
|
-
}))
|
|
261
|
-
// Define Effect handler
|
|
262
|
-
.effect(function* ({ input, errors }) {
|
|
263
|
-
const userService = yield* UserService
|
|
264
|
-
const user = yield* userService.findById(input.authorId)
|
|
265
|
-
|
|
266
|
-
if (!user) {
|
|
267
|
-
throw errors.NOT_FOUND()
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const postService = yield* PostService
|
|
271
|
-
return yield* postService.create({
|
|
272
|
-
title: input.title,
|
|
273
|
-
content: input.content,
|
|
274
|
-
authorId: input.authorId,
|
|
275
|
-
})
|
|
276
|
-
})
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
## Making Procedures Callable
|
|
280
|
-
|
|
281
|
-
Use `.callable()` to make procedures directly invocable:
|
|
282
|
-
|
|
283
|
-
```ts
|
|
284
|
-
const greet = effectOs
|
|
285
|
-
.input(z.object({ name: z.string() }))
|
|
286
|
-
.effect(function* ({ input }) {
|
|
287
|
-
return `Hello, ${input.name}!`
|
|
288
|
-
})
|
|
289
|
-
.callable()
|
|
290
|
-
|
|
291
|
-
// Can be called directly as a function
|
|
292
|
-
const result = await greet({ name: 'World' })
|
|
293
|
-
// => "Hello, World!"
|
|
294
|
-
|
|
295
|
-
// Still a valid procedure for routers
|
|
296
|
-
const router = os.router({ greet })
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
## Server Actions Support
|
|
300
|
-
|
|
301
|
-
Use `.actionable()` for framework server actions (Next.js, etc.):
|
|
129
|
+
## Error Handling
|
|
302
130
|
|
|
303
|
-
|
|
304
|
-
const createTodo = effectOs
|
|
305
|
-
.input(z.object({ title: z.string() }))
|
|
306
|
-
.effect(function* ({ input }) {
|
|
307
|
-
const todoService = yield* TodoService
|
|
308
|
-
return yield* todoService.create(input.title)
|
|
309
|
-
})
|
|
310
|
-
.actionable({ context: async () => ({ user: await getSession() }) })
|
|
311
|
-
|
|
312
|
-
// Use in React Server Components
|
|
313
|
-
export async function TodoForm() {
|
|
314
|
-
return (
|
|
315
|
-
<form action={createTodo}>
|
|
316
|
-
<input name="title" />
|
|
317
|
-
<button type="submit">Add Todo</button>
|
|
318
|
-
</form>
|
|
319
|
-
)
|
|
320
|
-
}
|
|
321
|
-
```
|
|
131
|
+
`ORPCTaggedError` lets you create Effect-native error classes that integrate seamlessly with oRPC. These errors:
|
|
322
132
|
|
|
323
|
-
|
|
133
|
+
- Can be yielded in Effect generators (`yield* new MyError()` or `yield* Effect.fail(errors.MyError)`)
|
|
134
|
+
- Can be used in Effect builder's `.errors()` maps for type-safe error handling alongside regular oRPC errors
|
|
135
|
+
- Automatically convert to ORPCError when thrown
|
|
324
136
|
|
|
325
|
-
|
|
137
|
+
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.
|
|
326
138
|
|
|
327
139
|
```ts
|
|
328
140
|
const getUser = effectOs
|
|
141
|
+
// Mixed error maps
|
|
329
142
|
.errors({
|
|
143
|
+
// Regular oRPC error
|
|
330
144
|
NOT_FOUND: {
|
|
331
|
-
message:
|
|
332
|
-
data: z.object({ id: z.string() })
|
|
145
|
+
message: "User not found",
|
|
146
|
+
data: z.object({ id: z.string() }),
|
|
333
147
|
},
|
|
148
|
+
// Effect oRPC tagged error
|
|
149
|
+
UserNotFoundError,
|
|
150
|
+
// Note: The key of an oRPC error is not used as the error code
|
|
151
|
+
// So the following will only change the key of the error when accessing it
|
|
152
|
+
// from the errors object passed to the handler, but not the actual error code itself.
|
|
153
|
+
// To change the error's code, please see the next section on creating tagged errors.
|
|
154
|
+
USER_NOT_FOUND: UserNotFoundError,
|
|
155
|
+
// ^^^ same code as the `UserNotFoundError` error key, defined at the class level
|
|
334
156
|
})
|
|
335
|
-
.input(z.object({ id: z.string() }))
|
|
336
157
|
.effect(function* ({ input, errors }) {
|
|
337
|
-
const
|
|
338
|
-
const user = yield* userService.findById(input.id)
|
|
339
|
-
|
|
158
|
+
const user = yield* UsersRepo.findById(input.id);
|
|
340
159
|
if (!user) {
|
|
341
|
-
|
|
342
|
-
|
|
160
|
+
return yield* new UserNotFoundError();
|
|
161
|
+
// or return `yield* Effect.fail(errors.USER_NOT_FOUND())`
|
|
343
162
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
})
|
|
163
|
+
return user;
|
|
164
|
+
});
|
|
347
165
|
```
|
|
348
166
|
|
|
349
|
-
## Tagged Errors
|
|
350
|
-
|
|
351
|
-
`ORPCTaggedError` lets you create Effect-native error classes that integrate seamlessly with oRPC. These errors:
|
|
352
|
-
|
|
353
|
-
- Can be yielded in Effect generators (`yield* new MyError()`)
|
|
354
|
-
- Have all ORPCError properties (code, status, data, defined)
|
|
355
|
-
- Can be used in `.errors()` maps for type-safe error handling
|
|
356
|
-
- Automatically convert to ORPCError when thrown
|
|
357
|
-
|
|
358
167
|
### Creating Tagged Errors
|
|
359
168
|
|
|
360
169
|
```ts
|
|
361
|
-
import { ORPCTaggedError } from
|
|
170
|
+
import { ORPCTaggedError } from "effect-orpc";
|
|
362
171
|
|
|
363
172
|
// Basic tagged error - code defaults to 'USER_NOT_FOUND' (CONSTANT_CASE of tag)
|
|
364
|
-
class UserNotFound extends ORPCTaggedError
|
|
173
|
+
class UserNotFound extends ORPCTaggedError()("UserNotFound") {}
|
|
365
174
|
|
|
366
175
|
// With explicit code
|
|
367
|
-
class NotFound extends ORPCTaggedError
|
|
176
|
+
class NotFound extends ORPCTaggedError()("NotFound", "NOT_FOUND") {}
|
|
368
177
|
|
|
369
178
|
// With default options (code defaults to 'VALIDATION_ERROR') (CONSTANT_CASE of tag)
|
|
370
|
-
class ValidationError extends ORPCTaggedError
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
) {}
|
|
179
|
+
class ValidationError extends ORPCTaggedError()("ValidationError", {
|
|
180
|
+
status: 400,
|
|
181
|
+
message: "Validation failed",
|
|
182
|
+
}) {}
|
|
374
183
|
|
|
375
184
|
// With explicit code and options
|
|
376
|
-
class Forbidden extends ORPCTaggedError
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
{ userId: string }
|
|
386
|
-
>()('UserNotFoundWithData') {}
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
### Using Tagged Errors in Procedures
|
|
390
|
-
|
|
391
|
-
Tagged errors can be yielded directly in Effect generators:
|
|
392
|
-
|
|
393
|
-
```ts
|
|
394
|
-
class UserNotFound extends ORPCTaggedError<
|
|
395
|
-
UserNotFound,
|
|
396
|
-
{ userId: string }
|
|
397
|
-
>()('UserNotFound', { status: 404, message: 'User not found' })
|
|
398
|
-
|
|
399
|
-
const getUser = effectOs
|
|
400
|
-
.input(z.object({ id: z.string() }))
|
|
401
|
-
.effect(function* ({ input }) {
|
|
402
|
-
const userService = yield* UserService
|
|
403
|
-
const user = yield* userService.findById(input.id)
|
|
404
|
-
|
|
405
|
-
if (!user) {
|
|
406
|
-
// Yield the error - it will be converted to ORPCError automatically
|
|
407
|
-
return yield* new UserNotFound({ data: { userId: input.id } })
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return user
|
|
411
|
-
})
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
### Using Tagged Errors in Error Maps
|
|
415
|
-
|
|
416
|
-
Tagged error classes can be passed directly to `.errors()`:
|
|
417
|
-
|
|
418
|
-
```ts
|
|
419
|
-
class UserNotFound extends ORPCTaggedError<
|
|
420
|
-
UserNotFound,
|
|
421
|
-
{ userId: string }
|
|
422
|
-
>()('UserNotFound', 'NOT_FOUND', { status: 404, message: 'User not found' })
|
|
423
|
-
|
|
424
|
-
class InvalidInput extends ORPCTaggedError<
|
|
425
|
-
InvalidInput,
|
|
426
|
-
{ field: string }
|
|
427
|
-
>()('InvalidInput', 'BAD_REQUEST', { status: 400 })
|
|
428
|
-
|
|
429
|
-
const getUser = effectOs
|
|
430
|
-
.errors({
|
|
431
|
-
// Tagged error class - use the class directly
|
|
432
|
-
// The only difference is that the code is defined by the constant version of the tag
|
|
433
|
-
// Or when defined explicitely like in the Forbidden tagged error above
|
|
434
|
-
UserNotFound,
|
|
435
|
-
INVALID_INPUT: InvalidInput,
|
|
436
|
-
// Traditional format still works, and can be colocated
|
|
437
|
-
INTERNAL_ERROR: { status: 500, message: 'Something went wrong' },
|
|
438
|
-
})
|
|
439
|
-
.input(z.object({ id: z.string() }))
|
|
440
|
-
.effect(function* ({ input, errors }) {
|
|
441
|
-
if (!input.id) {
|
|
442
|
-
// errors.BAD_REQUEST is the InvalidInput class
|
|
443
|
-
return yield* new errors.INVALID_INPUT({ data: { field: 'id' } })
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const userService = yield* UserService
|
|
447
|
-
const user = yield* userService.findById(input.id)
|
|
448
|
-
|
|
449
|
-
if (!user) {
|
|
450
|
-
// errors.UserNotFound is the UserNotFound class
|
|
451
|
-
// with the code USER_NOT_FOUND (defined at the class level)
|
|
452
|
-
return yield* new errors.UserNotFound({ data: { userId: input.id } })
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
return user
|
|
456
|
-
})
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
### Converting Tagged Errors
|
|
460
|
-
|
|
461
|
-
Use `toORPCError` to convert a tagged error to a plain ORPCError:
|
|
462
|
-
|
|
463
|
-
```ts
|
|
464
|
-
import { toORPCError, ORPCTaggedError } from 'effect-orpc'
|
|
465
|
-
import { Effect } from 'effect'
|
|
466
|
-
|
|
467
|
-
class MyError extends ORPCTaggedError<MyError>()('MyError', 'BAD_REQUEST') {}
|
|
468
|
-
|
|
469
|
-
const procedure = effectOs.effect(function* () {
|
|
470
|
-
const result = yield* someOperation.pipe(
|
|
471
|
-
Effect.catchTag('MyError', (e) =>
|
|
472
|
-
// Convert to plain ORPCError if needed
|
|
473
|
-
Effect.fail(toORPCError(e))
|
|
474
|
-
)
|
|
475
|
-
)
|
|
476
|
-
return result
|
|
477
|
-
})
|
|
478
|
-
```
|
|
479
|
-
|
|
480
|
-
### Tagged Error Properties
|
|
481
|
-
|
|
482
|
-
Tagged errors have all the properties of ORPCError plus Effect integration:
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
const error = new UserNotFound({
|
|
486
|
-
data: { userId: '123' },
|
|
487
|
-
message: 'Custom message', // Override default message
|
|
488
|
-
cause: originalError, // Attach cause for debugging
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
error._tag // 'UserNotFound' - for Effect's catchTag
|
|
492
|
-
error.code // 'USER_NOT_FOUND' - ORPCError code
|
|
493
|
-
error.status // 404 - HTTP status
|
|
494
|
-
error.data // { userId: '123' } - typed data
|
|
495
|
-
error.message // 'Custom message'
|
|
496
|
-
error.defined // true - whether error is defined in error map
|
|
497
|
-
|
|
498
|
-
// Convert to plain ORPCError
|
|
499
|
-
const orpcError = error.toORPCError()
|
|
500
|
-
|
|
501
|
-
// Serialize to JSON
|
|
502
|
-
const json = error.toJSON()
|
|
503
|
-
// { _tag: 'UserNotFound', code: 'USER_NOT_FOUND', status: 404, ... }
|
|
185
|
+
class Forbidden extends ORPCTaggedError()("Forbidden", "FORBIDDEN", {
|
|
186
|
+
status: 403,
|
|
187
|
+
message: "Access denied",
|
|
188
|
+
}) {}
|
|
189
|
+
|
|
190
|
+
// With typed data using Standard Schema
|
|
191
|
+
class UserNotFoundWithData extends ORPCTaggedError(
|
|
192
|
+
z.object({ userId: z.string() }),
|
|
193
|
+
)("UserNotFoundWithData") {}
|
|
504
194
|
```
|
|
505
195
|
|
|
506
|
-
## Generator Syntax
|
|
507
|
-
|
|
508
|
-
Pass a generator function directly to `.effect()` — no need to wrap it with `Effect.fn()` or `Effect.gen()`:
|
|
509
|
-
|
|
510
|
-
```ts
|
|
511
|
-
// Recommended: Pass generator function directly
|
|
512
|
-
const procedureWithGen = effectOs
|
|
513
|
-
.input(z.object({ id: z.string() }))
|
|
514
|
-
.effect(function* ({ input }) {
|
|
515
|
-
const service = yield* MyService
|
|
516
|
-
return yield* service.doSomething(input.id)
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
// Simple procedures without yield*
|
|
520
|
-
const simpleProcedure = effectOs
|
|
521
|
-
.input(z.object({ name: z.string() }))
|
|
522
|
-
.effect(function* ({ input }) {
|
|
523
|
-
return `Hello, ${input.name}!`
|
|
524
|
-
})
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
The handler receives `{ context, input, path, procedure, signal, lastEventId, errors }` as its argument, giving you full access to the oRPC procedure context.
|
|
528
|
-
|
|
529
196
|
## Traceable Spans
|
|
530
197
|
|
|
531
198
|
All Effect procedures are automatically traced with `Effect.withSpan`. By default, the span name is the procedure path (e.g., `users.getUser`):
|
|
532
199
|
|
|
533
200
|
```ts
|
|
534
201
|
// Router structure determines span names automatically
|
|
535
|
-
const router =
|
|
536
|
-
users:
|
|
202
|
+
const router = {
|
|
203
|
+
users: {
|
|
537
204
|
// Span name: "users.get"
|
|
538
|
-
get: effectOs
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
205
|
+
get: effectOs.input(z.object({ id: z.string() })).effect(function* ({
|
|
206
|
+
input,
|
|
207
|
+
}) {
|
|
208
|
+
const userService = yield* UserService;
|
|
209
|
+
return yield* userService.findById(input.id);
|
|
210
|
+
}),
|
|
544
211
|
// Span name: "users.create"
|
|
545
|
-
create: effectOs
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
}
|
|
212
|
+
create: effectOs.input(z.object({ name: z.string() })).effect(function* ({
|
|
213
|
+
input,
|
|
214
|
+
}) {
|
|
215
|
+
const userService = yield* UserService;
|
|
216
|
+
return yield* userService.create(input.name);
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
};
|
|
553
220
|
```
|
|
554
221
|
|
|
555
222
|
Use `.traced()` to override the default span name:
|
|
@@ -557,11 +224,11 @@ Use `.traced()` to override the default span name:
|
|
|
557
224
|
```ts
|
|
558
225
|
const getUser = effectOs
|
|
559
226
|
.input(z.object({ id: z.string() }))
|
|
560
|
-
.traced(
|
|
227
|
+
.traced("custom.span.name") // Override the default path-based name
|
|
561
228
|
.effect(function* ({ input }) {
|
|
562
|
-
const userService = yield* UserService
|
|
563
|
-
return yield* userService.findById(input.id)
|
|
564
|
-
})
|
|
229
|
+
const userService = yield* UserService;
|
|
230
|
+
return yield* userService.findById(input.id);
|
|
231
|
+
});
|
|
565
232
|
```
|
|
566
233
|
|
|
567
234
|
### Enabling OpenTelemetry
|
|
@@ -569,22 +236,21 @@ const getUser = effectOs
|
|
|
569
236
|
To enable tracing, include the OpenTelemetry layer in your runtime:
|
|
570
237
|
|
|
571
238
|
```ts
|
|
572
|
-
import { NodeSdk } from
|
|
573
|
-
import { OTLPTraceExporter } from
|
|
574
|
-
import { SimpleSpanProcessor } from
|
|
575
|
-
|
|
576
|
-
const TracingLive = NodeSdk.layer(
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
const effectOs = makeEffectORPC(runtime)
|
|
239
|
+
import { NodeSdk } from "@effect/opentelemetry";
|
|
240
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
241
|
+
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
242
|
+
|
|
243
|
+
const TracingLive = NodeSdk.layer(
|
|
244
|
+
Effect.sync(() => ({
|
|
245
|
+
resource: { serviceName: "my-service" },
|
|
246
|
+
spanProcessor: [new SimpleSpanProcessor(new OTLPTraceExporter())],
|
|
247
|
+
})),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const AppLive = Layer.mergeAll(UserServiceLive, TracingLive);
|
|
251
|
+
|
|
252
|
+
const runtime = ManagedRuntime.make(AppLive);
|
|
253
|
+
const effectOs = makeEffectORPC(runtime);
|
|
588
254
|
```
|
|
589
255
|
|
|
590
256
|
### Error Stack Traces
|
|
@@ -610,26 +276,36 @@ Returns an `EffectBuilder` instance.
|
|
|
610
276
|
|
|
611
277
|
```ts
|
|
612
278
|
// With default builder
|
|
613
|
-
const effectOs = makeEffectORPC(runtime)
|
|
279
|
+
const effectOs = makeEffectORPC(runtime);
|
|
614
280
|
|
|
615
281
|
// With customized builder
|
|
616
|
-
const effectAuthedOs = makeEffectORPC(runtime, authedBuilder)
|
|
282
|
+
const effectAuthedOs = makeEffectORPC(runtime, authedBuilder);
|
|
617
283
|
```
|
|
618
284
|
|
|
619
285
|
### `EffectBuilder`
|
|
620
286
|
|
|
621
287
|
Wraps an oRPC Builder with Effect support. Available methods:
|
|
622
288
|
|
|
623
|
-
| Method
|
|
624
|
-
|
|
|
625
|
-
|
|
|
626
|
-
|
|
|
627
|
-
|
|
|
628
|
-
|
|
|
629
|
-
|
|
|
630
|
-
| `.
|
|
631
|
-
| `.
|
|
632
|
-
| `.
|
|
289
|
+
| Method | Description |
|
|
290
|
+
| ------------------- | ------------------------------------------------------------------------------- |
|
|
291
|
+
| `.$config(config)` | Set or override the builder config |
|
|
292
|
+
| `.$context<U>()` | Set or override the initial context type |
|
|
293
|
+
| `.$meta(meta)` | Set or override the initial metadata |
|
|
294
|
+
| `.$route(route)` | Set or override the initial route configuration |
|
|
295
|
+
| `.$input(schema)` | Set or override the initial input schema |
|
|
296
|
+
| `.errors(map)` | Add type-safe custom errors |
|
|
297
|
+
| `.meta(meta)` | Set procedure metadata (merged with existing) |
|
|
298
|
+
| `.route(route)` | Configure OpenAPI route (merged with existing) |
|
|
299
|
+
| `.input(schema)` | Define input validation schema |
|
|
300
|
+
| `.output(schema)` | Define output validation schema |
|
|
301
|
+
| `.use(middleware)` | Add middleware |
|
|
302
|
+
| `.traced(name)` | Add a traceable span for telemetry (optional, defaults to the procedure's path) |
|
|
303
|
+
| `.handler(handler)` | Define a non-Effect handler (standard oRPC handler) |
|
|
304
|
+
| `.effect(handler)` | Define the Effect handler |
|
|
305
|
+
| `.prefix(prefix)` | Prefix all procedures in the router (for OpenAPI) |
|
|
306
|
+
| `.tag(...tags)` | Add tags to all procedures in the router (for OpenAPI) |
|
|
307
|
+
| `.router(router)` | Apply all options to a router |
|
|
308
|
+
| `.lazy(loader)` | Create and apply options to a lazy-loaded router |
|
|
633
309
|
|
|
634
310
|
### `EffectDecoratedProcedure`
|
|
635
311
|
|
|
@@ -638,60 +314,22 @@ The result of calling `.effect()`. Extends standard oRPC `DecoratedProcedure` wi
|
|
|
638
314
|
| Method | Description |
|
|
639
315
|
| ----------------------- | --------------------------------------------- |
|
|
640
316
|
| `.errors(map)` | Add more custom errors |
|
|
641
|
-
| `.meta(meta)` | Update metadata
|
|
642
|
-
| `.route(route)` | Update route configuration
|
|
317
|
+
| `.meta(meta)` | Update metadata (merged with existing) |
|
|
318
|
+
| `.route(route)` | Update route configuration (merged) |
|
|
643
319
|
| `.use(middleware)` | Add middleware |
|
|
644
320
|
| `.callable(options?)` | Make procedure directly invocable |
|
|
645
321
|
| `.actionable(options?)` | Make procedure compatible with server actions |
|
|
646
322
|
|
|
647
|
-
### `ORPCTaggedError
|
|
323
|
+
### `ORPCTaggedError(schema?)(tag, codeOrOptions?, defaultOptions?)`
|
|
648
324
|
|
|
649
325
|
Factory function to create Effect-native tagged error classes.
|
|
326
|
+
If no code is provided, it defaults to CONSTANT_CASE of the tag (e.g., `UserNotFoundError` → `USER_NOT_FOUND_ERROR`).
|
|
650
327
|
|
|
651
|
-
- `
|
|
652
|
-
- `TData` - Optional type for the error's data payload
|
|
328
|
+
- `schema` - Optional Standard Schema for the error's data payload (e.g., `z.object({ userId: z.string() })`)
|
|
653
329
|
- `tag` - Unique tag for discriminated unions (used by Effect's `catchTag`)
|
|
654
330
|
- `codeOrOptions` - Either an ORPCErrorCode string or `{ status?, message? }` options
|
|
655
331
|
- `defaultOptions` - Default `{ status?, message? }` when code is provided explicitly
|
|
656
332
|
|
|
657
|
-
If no code is provided, it defaults to CONSTANT_CASE of the tag (e.g., `UserNotFound` → `USER_NOT_FOUND`).
|
|
658
|
-
|
|
659
|
-
```ts
|
|
660
|
-
// Tag only - code defaults to 'MY_ERROR'
|
|
661
|
-
class MyError extends ORPCTaggedError<MyError>()('MyError') {}
|
|
662
|
-
|
|
663
|
-
// With options - code defaults to 'MY_ERROR'
|
|
664
|
-
class MyError extends ORPCTaggedError<MyError>()(
|
|
665
|
-
'MyError',
|
|
666
|
-
{ status: 400, message: 'Bad request' }
|
|
667
|
-
) {}
|
|
668
|
-
|
|
669
|
-
// With explicit code
|
|
670
|
-
class MyError extends ORPCTaggedError<MyError>()(
|
|
671
|
-
'MyError',
|
|
672
|
-
'CUSTOM_CODE',
|
|
673
|
-
{ status: 400 }
|
|
674
|
-
) {}
|
|
675
|
-
|
|
676
|
-
// With typed data
|
|
677
|
-
class MyError extends ORPCTaggedError<MyError, { field: string }>()(
|
|
678
|
-
'MyError',
|
|
679
|
-
'BAD_REQUEST'
|
|
680
|
-
) {}
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
### `toORPCError(taggedError)`
|
|
684
|
-
|
|
685
|
-
Converts an `ORPCTaggedError` instance to a plain `ORPCError`.
|
|
686
|
-
|
|
687
|
-
```ts
|
|
688
|
-
import { toORPCError } from 'effect-orpc'
|
|
689
|
-
|
|
690
|
-
const taggedError = new UserNotFound({ data: { userId: '123' } })
|
|
691
|
-
const orpcError = toORPCError(taggedError)
|
|
692
|
-
// => ORPCError { code: 'USER_NOT_FOUND', status: 404, data: { userId: '123' } }
|
|
693
|
-
```
|
|
694
|
-
|
|
695
333
|
## License
|
|
696
334
|
|
|
697
335
|
MIT
|