effect-orpc 0.0.1

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.
@@ -0,0 +1,515 @@
1
+ import type {
2
+ ORPCErrorCode,
3
+ ORPCErrorJSON,
4
+ ORPCErrorOptions,
5
+ } from "@orpc/client";
6
+ import type { AnySchema, ErrorMap, ErrorMapItem } from "@orpc/contract";
7
+ import type { ORPCErrorConstructorMapItemOptions } from "@orpc/server";
8
+ import type { MaybeOptionalOptions } from "@orpc/shared";
9
+ import type { Pipeable, Types } from "effect";
10
+ import type * as Cause from "effect/Cause";
11
+ import type * as Effect from "effect/Effect";
12
+
13
+ import {
14
+ fallbackORPCErrorMessage,
15
+ fallbackORPCErrorStatus,
16
+ isORPCErrorStatus,
17
+ ORPCError,
18
+ } from "@orpc/client";
19
+ import { resolveMaybeOptionalOptions } from "@orpc/shared";
20
+ import * as Data from "effect/Data";
21
+
22
+ /**
23
+ * Symbol to access the underlying ORPCError instance
24
+ */
25
+ export const ORPCErrorSymbol: unique symbol = Symbol.for(
26
+ "@orpc/effect/ORPCTaggedError",
27
+ );
28
+
29
+ /**
30
+ * Instance type for ORPCTaggedError that combines YieldableError with ORPCError properties
31
+ */
32
+ export interface ORPCTaggedErrorInstance<
33
+ TTag extends string,
34
+ TCode extends ORPCErrorCode,
35
+ TData,
36
+ >
37
+ extends Cause.YieldableError, Pipeable.Pipeable {
38
+ readonly _tag: TTag;
39
+ readonly code: TCode;
40
+ readonly status: number;
41
+ readonly data: TData;
42
+ readonly defined: boolean;
43
+ readonly [ORPCErrorSymbol]: ORPCError<TCode, TData>;
44
+
45
+ toJSON(): ORPCErrorJSON<TCode, TData> & { _tag: TTag };
46
+ toORPCError(): ORPCError<TCode, TData>;
47
+ commit(): Effect.Effect<never, this, never>;
48
+ }
49
+
50
+ /**
51
+ * Options for creating an ORPCTaggedError
52
+ */
53
+ export type ORPCTaggedErrorOptions<TData> = Omit<
54
+ ORPCErrorOptions<TData>,
55
+ "defined"
56
+ > & { defined?: boolean };
57
+
58
+ /**
59
+ * Constructor type for ORPCTaggedError classes
60
+ */
61
+ export interface ORPCTaggedErrorClass<
62
+ TTag extends string,
63
+ TCode extends ORPCErrorCode,
64
+ TData,
65
+ > {
66
+ readonly _tag: TTag;
67
+ readonly code: TCode;
68
+ new (
69
+ ...args: MaybeOptionalOptions<ORPCTaggedErrorOptions<TData>>
70
+ ): ORPCTaggedErrorInstance<TTag, TCode, TData>;
71
+ }
72
+
73
+ /**
74
+ * Type helper to infer the ORPCError type from an ORPCTaggedError
75
+ */
76
+ export type InferORPCError<T> =
77
+ T extends ORPCTaggedErrorInstance<string, infer TCode, infer TData>
78
+ ? ORPCError<TCode, TData>
79
+ : never;
80
+
81
+ /**
82
+ * Any ORPCTaggedErrorClass
83
+ */
84
+ export type AnyORPCTaggedErrorClass = ORPCTaggedErrorClass<
85
+ string,
86
+ ORPCErrorCode,
87
+ any
88
+ >;
89
+
90
+ /**
91
+ * Check if a value is an ORPCTaggedErrorClass (constructor)
92
+ */
93
+ export function isORPCTaggedErrorClass(
94
+ value: unknown,
95
+ ): value is AnyORPCTaggedErrorClass {
96
+ return (
97
+ typeof value === "function" &&
98
+ "_tag" in value &&
99
+ "code" in value &&
100
+ typeof value._tag === "string" &&
101
+ typeof value.code === "string"
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Check if a value is an ORPCTaggedError instance
107
+ */
108
+ export function isORPCTaggedError(
109
+ value: unknown,
110
+ ): value is ORPCTaggedErrorInstance<string, ORPCErrorCode, unknown> {
111
+ return (
112
+ typeof value === "object" && value !== null && ORPCErrorSymbol in value
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Converts a PascalCase or camelCase string to CONSTANT_CASE.
118
+ * e.g., "UserNotFoundError" -> "USER_NOT_FOUND_ERROR"
119
+ */
120
+ function toConstantCase(str: string): string {
121
+ return str
122
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
123
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
124
+ .toUpperCase();
125
+ }
126
+
127
+ // Type-level conversion: split on capital letters and join with underscore
128
+ type SplitOnCapital<
129
+ S extends string,
130
+ Acc extends string = "",
131
+ > = S extends `${infer Head}${infer Tail}`
132
+ ? Head extends Uppercase<Head>
133
+ ? Head extends Lowercase<Head>
134
+ ? SplitOnCapital<Tail, `${Acc}${Head}`>
135
+ : Acc extends ""
136
+ ? SplitOnCapital<Tail, Head>
137
+ : `${Acc}_${SplitOnCapital<Tail, Head>}`
138
+ : SplitOnCapital<Tail, `${Acc}${Uppercase<Head>}`>
139
+ : Acc;
140
+
141
+ /**
142
+ * Converts a tag name to an error code in CONSTANT_CASE.
143
+ */
144
+ export type TagToCode<TTag extends string> = SplitOnCapital<TTag>;
145
+
146
+ /**
147
+ * Creates a tagged error class that combines Effect's YieldableError with ORPCError.
148
+ *
149
+ * This allows you to create errors that:
150
+ * - Can be yielded in Effect generators (`yield* myError`)
151
+ * - Have all ORPCError properties (code, status, data, defined)
152
+ * - Can be converted to a plain ORPCError for oRPC handlers
153
+ *
154
+ * The returned factory function takes:
155
+ * - `tag` - The unique tag for this error type (used for discriminated unions)
156
+ * - `codeOrOptions` - Optional ORPC error code or options. If omitted, code defaults to CONSTANT_CASE of tag
157
+ * - `defaultOptions` - Optional default options for status and message (when code is provided)
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * import { ORPCTaggedError } from '@orpc/effect'
162
+ * import { Effect } from 'effect'
163
+ *
164
+ * // Define a custom error (code defaults to 'USER_NOT_FOUND_ERROR')
165
+ * class UserNotFoundError extends ORPCTaggedError<UserNotFoundError>()('UserNotFoundError') {}
166
+ *
167
+ * // With explicit code
168
+ * class NotFoundError extends ORPCTaggedError<NotFoundError>()('NotFoundError', 'NOT_FOUND') {}
169
+ *
170
+ * // Use in an Effect
171
+ * const getUser = (id: string) => Effect.gen(function* () {
172
+ * const user = yield* findUser(id)
173
+ * if (!user) {
174
+ * return yield* new UserNotFoundError({ data: { userId: id } })
175
+ * }
176
+ * return user
177
+ * })
178
+ *
179
+ * // With custom data type
180
+ * class ValidationError extends ORPCTaggedError<ValidationError, { fields: string[] }>()('ValidationError', 'BAD_REQUEST') {}
181
+ *
182
+ * // With options only (code defaults to 'VALIDATION_ERROR')
183
+ * class ValidationError2 extends ORPCTaggedError<ValidationError2, { fields: string[] }>()(
184
+ * 'ValidationError2',
185
+ * { message: 'Validation failed' }
186
+ * ) {}
187
+ * ```
188
+ */
189
+ /**
190
+ * Return type for the factory function with overloads
191
+ */
192
+ interface ORPCTaggedErrorFactory<Self, TData> {
193
+ // Overload 1: tag only (code defaults to CONSTANT_CASE of tag)
194
+ <TTag extends string>(
195
+ tag: TTag,
196
+ ): Types.Equals<Self, unknown> extends true
197
+ ? `Missing \`Self\` generic - use \`class MyError extends ORPCTaggedError<MyError>()(tag) {}\``
198
+ : ORPCTaggedErrorClass<TTag, TagToCode<TTag>, TData>;
199
+
200
+ // Overload 2: tag + options (code defaults to CONSTANT_CASE of tag)
201
+ <TTag extends string>(
202
+ tag: TTag,
203
+ options: { status?: number; message?: string },
204
+ ): Types.Equals<Self, unknown> extends true
205
+ ? `Missing \`Self\` generic - use \`class MyError extends ORPCTaggedError<MyError>()(tag, options) {}\``
206
+ : ORPCTaggedErrorClass<TTag, TagToCode<TTag>, TData>;
207
+
208
+ // Overload 3: tag + explicit code
209
+ <TTag extends string, TCode extends ORPCErrorCode>(
210
+ tag: TTag,
211
+ code: TCode,
212
+ defaultOptions?: { status?: number; message?: string },
213
+ ): Types.Equals<Self, unknown> extends true
214
+ ? `Missing \`Self\` generic - use \`class MyError extends ORPCTaggedError<MyError>()(tag, code) {}\``
215
+ : ORPCTaggedErrorClass<TTag, TCode, TData>;
216
+ }
217
+
218
+ export function ORPCTaggedError<
219
+ Self,
220
+ TData = undefined,
221
+ >(): ORPCTaggedErrorFactory<Self, TData> {
222
+ const factory = <TTag extends string, TCode extends ORPCErrorCode>(
223
+ tag: TTag,
224
+ codeOrOptions?: TCode | { status?: number; message?: string },
225
+ defaultOptions?: { status?: number; message?: string },
226
+ ): ORPCTaggedErrorClass<TTag, TCode, TData> => {
227
+ // Determine if second arg is code or options
228
+ const isCodeProvided = typeof codeOrOptions === "string";
229
+ const code = (
230
+ isCodeProvided ? codeOrOptions : toConstantCase(tag)
231
+ ) as TCode;
232
+ const options = isCodeProvided ? defaultOptions : codeOrOptions;
233
+
234
+ const defaultStatus = options?.status;
235
+ const defaultMessage = options?.message;
236
+
237
+ // Use Effect's TaggedError as the base - this handles all Effect internals
238
+ // (YieldableError, type symbols, commit(), Symbol.iterator, pipe(), etc.)
239
+ const BaseTaggedError = Data.TaggedError(tag) as unknown as new (args: {
240
+ message?: string;
241
+ cause?: unknown;
242
+ code: TCode;
243
+ status: number;
244
+ data: TData;
245
+ defined: boolean;
246
+ }) => Cause.YieldableError & {
247
+ readonly _tag: TTag;
248
+ readonly code: TCode;
249
+ readonly status: number;
250
+ readonly data: TData;
251
+ readonly defined: boolean;
252
+ };
253
+
254
+ class ORPCTaggedErrorBase extends BaseTaggedError {
255
+ static readonly _tag = tag;
256
+ static readonly code = code;
257
+
258
+ readonly [ORPCErrorSymbol]: ORPCError<TCode, TData>;
259
+
260
+ constructor(
261
+ ...rest: MaybeOptionalOptions<ORPCTaggedErrorOptions<TData>>
262
+ ) {
263
+ const opts = resolveMaybeOptionalOptions(rest);
264
+ const status = opts.status ?? defaultStatus;
265
+ const inputMessage = opts.message ?? defaultMessage;
266
+
267
+ if (status !== undefined && !isORPCErrorStatus(status)) {
268
+ throw new globalThis.Error(
269
+ "[ORPCTaggedError] Invalid error status code.",
270
+ );
271
+ }
272
+
273
+ const finalStatus = fallbackORPCErrorStatus(code, status);
274
+ const finalMessage = fallbackORPCErrorMessage(code, inputMessage);
275
+
276
+ // Pass to Effect's TaggedError - it spreads these onto the instance
277
+ super({
278
+ message: finalMessage,
279
+ cause: opts.cause,
280
+ code,
281
+ status: finalStatus,
282
+ data: opts.data as TData,
283
+ defined: opts.defined ?? true,
284
+ });
285
+
286
+ // Create the underlying ORPCError for interop
287
+ this[ORPCErrorSymbol] = new ORPCError(code, {
288
+ status: finalStatus,
289
+ message: finalMessage,
290
+ data: opts.data as TData,
291
+ defined: this.defined,
292
+ cause: opts.cause,
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Converts this error to a plain ORPCError.
298
+ * Useful when you need to return from an oRPC handler.
299
+ */
300
+ toORPCError(): ORPCError<TCode, TData> {
301
+ return this[ORPCErrorSymbol];
302
+ }
303
+
304
+ override toJSON(): ORPCErrorJSON<TCode, TData> & { _tag: TTag } {
305
+ return {
306
+ _tag: this._tag,
307
+ defined: this.defined,
308
+ code: this.code,
309
+ status: this.status,
310
+ message: this.message,
311
+ data: this.data,
312
+ };
313
+ }
314
+ }
315
+
316
+ return ORPCTaggedErrorBase as any;
317
+ };
318
+
319
+ return factory as ORPCTaggedErrorFactory<Self, TData>;
320
+ }
321
+
322
+ /**
323
+ * Converts an ORPCTaggedError to a plain ORPCError.
324
+ * Useful in handlers that need to throw ORPCError.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * const handler = effectOs.effect(function* () {
329
+ * const result = yield* someOperation.pipe(
330
+ * Effect.catchTag('UserNotFoundError', (e) =>
331
+ * Effect.fail(toORPCError(e))
332
+ * )
333
+ * )
334
+ * return result
335
+ * })
336
+ * ```
337
+ */
338
+ export function toORPCError<TCode extends ORPCErrorCode, TData>(
339
+ error: ORPCTaggedErrorInstance<string, TCode, TData>,
340
+ ): ORPCError<TCode, TData> {
341
+ return error[ORPCErrorSymbol];
342
+ }
343
+
344
+ // ============================================================================
345
+ // Extended Error Map Types for Effect
346
+ // ============================================================================
347
+
348
+ /**
349
+ * An item in the EffectErrorMap - can be either a traditional ErrorMapItem or an ORPCTaggedErrorClass
350
+ */
351
+ export type EffectErrorMapItem =
352
+ | ErrorMapItem<AnySchema>
353
+ | AnyORPCTaggedErrorClass;
354
+
355
+ /**
356
+ * Extended error map that supports both traditional oRPC errors and ORPCTaggedError classes.
357
+ *
358
+ * @example
359
+ * ```ts
360
+ * const errorMap = {
361
+ * // Traditional format
362
+ * BAD_REQUEST: { status: 400, message: 'Bad request' },
363
+ *
364
+ * // Tagged error class reference
365
+ * USER_NOT_FOUND: UserNotFoundError,
366
+ * } satisfies EffectErrorMap
367
+ * ```
368
+ */
369
+ export type EffectErrorMap = {
370
+ [key in ORPCErrorCode]?: EffectErrorMapItem;
371
+ };
372
+
373
+ /**
374
+ * Merges two EffectErrorMaps, with the second map taking precedence.
375
+ */
376
+ export type MergedEffectErrorMap<
377
+ T1 extends EffectErrorMap,
378
+ T2 extends EffectErrorMap,
379
+ > = T1 & T2;
380
+
381
+ /**
382
+ * Extracts the instance type from an EffectErrorMapItem
383
+ */
384
+ export type EffectErrorMapItemToInstance<
385
+ TCode extends ORPCErrorCode,
386
+ T extends EffectErrorMapItem,
387
+ > = T extends AnyORPCTaggedErrorClass
388
+ ? InstanceType<T>
389
+ : T extends { data?: infer TData }
390
+ ? ORPCError<TCode, TData>
391
+ : ORPCError<TCode, unknown>;
392
+
393
+ /**
394
+ * Converts an EffectErrorMap to a union of error instances.
395
+ */
396
+ export type EffectErrorMapToUnion<T extends EffectErrorMap> = {
397
+ [K in keyof T]: K extends ORPCErrorCode
398
+ ? T[K] extends EffectErrorMapItem
399
+ ? EffectErrorMapItemToInstance<K, T[K]>
400
+ : never
401
+ : never;
402
+ }[keyof T];
403
+
404
+ /**
405
+ * Type for the error constructors available in Effect handlers.
406
+ * For tagged errors, it's the class constructor itself.
407
+ * For traditional errors, it's a function that creates ORPCError.
408
+ */
409
+ export type EffectErrorConstructorMapItem<
410
+ TCode extends ORPCErrorCode,
411
+ T extends EffectErrorMapItem,
412
+ > =
413
+ T extends ORPCTaggedErrorClass<infer _TTag, TCode, infer TData>
414
+ ? ORPCTaggedErrorClass<_TTag, TCode, TData>
415
+ : T extends { data?: infer TData }
416
+ ? (
417
+ ...args: MaybeOptionalOptions<
418
+ ORPCErrorConstructorMapItemOptions<TData>
419
+ >
420
+ ) => ORPCError<TCode, TData>
421
+ : (
422
+ ...args: MaybeOptionalOptions<
423
+ ORPCErrorConstructorMapItemOptions<unknown>
424
+ >
425
+ ) => ORPCError<TCode, unknown>;
426
+
427
+ /**
428
+ * Constructor map for EffectErrorMap - provides typed error constructors for handlers.
429
+ */
430
+ export type EffectErrorConstructorMap<T extends EffectErrorMap> = {
431
+ [K in keyof T]: K extends ORPCErrorCode
432
+ ? T[K] extends EffectErrorMapItem
433
+ ? EffectErrorConstructorMapItem<K, T[K]>
434
+ : never
435
+ : never;
436
+ };
437
+
438
+ /**
439
+ * Creates an error constructor map from an EffectErrorMap.
440
+ * Tagged error classes are passed through directly.
441
+ * Traditional error items become ORPCError factory functions.
442
+ */
443
+ export function createEffectErrorConstructorMap<T extends EffectErrorMap>(
444
+ errors: T | undefined,
445
+ ): EffectErrorConstructorMap<T> {
446
+ const target = errors ?? ({} as T);
447
+ const proxy = new Proxy(target, {
448
+ get(proxyTarget, code) {
449
+ if (typeof code !== "string") {
450
+ return Reflect.get(proxyTarget, code);
451
+ }
452
+
453
+ const config = target[code];
454
+
455
+ // If it's a tagged error class, return it directly
456
+ if (isORPCTaggedErrorClass(config)) {
457
+ return config;
458
+ }
459
+
460
+ // Otherwise, create a factory function for ORPCError
461
+ return (
462
+ ...rest: MaybeOptionalOptions<
463
+ Omit<ORPCErrorOptions<unknown>, "defined" | "status">
464
+ >
465
+ ) => {
466
+ const options = resolveMaybeOptionalOptions(rest);
467
+ return new ORPCError(code, {
468
+ defined: Boolean(config),
469
+ status: config?.status,
470
+ message: options.message ?? config?.message,
471
+ data: options.data,
472
+ cause: options.cause,
473
+ });
474
+ };
475
+ },
476
+ });
477
+
478
+ return proxy as EffectErrorConstructorMap<T>;
479
+ }
480
+
481
+ /**
482
+ * Converts an EffectErrorMap to a standard oRPC ErrorMap for interop.
483
+ * Tagged error classes are converted to their equivalent ErrorMapItem format.
484
+ */
485
+ export function effectErrorMapToErrorMap<T extends EffectErrorMap>(
486
+ errorMap: T | undefined,
487
+ ): ErrorMap {
488
+ const result: ErrorMap = {};
489
+
490
+ if (!errorMap) {
491
+ return result;
492
+ }
493
+
494
+ for (const [code, ClassOrErrorItem] of Object.entries(errorMap)) {
495
+ if (!ClassOrErrorItem) {
496
+ continue;
497
+ }
498
+
499
+ if (isORPCTaggedErrorClass(ClassOrErrorItem)) {
500
+ const error = new ClassOrErrorItem().toORPCError();
501
+
502
+ // For tagged errors, we create a minimal entry
503
+ // The actual validation will be handled by the tagged error class
504
+ result[code] = {
505
+ status: error.status,
506
+ message: error.message,
507
+ data: error.data,
508
+ };
509
+ } else {
510
+ result[code] = ClassOrErrorItem;
511
+ }
512
+ }
513
+
514
+ return result;
515
+ }