Skip to content

Commit 4dc0866

Browse files
authored
feat(contract): error factory (#1760)
Adds an `error()` factory to `@orpc/contract` that creates reusable `ORPCError` classes bound to a code, default message, and data schema. A factory class can be thrown anywhere, registered directly in `.errors()` under its code, and checked with `instanceof` with full type narrowing. The `createORPCErrorConstructorMap` utilities also move from `@orpc/server` into `@orpc/contract` so the whole error map machinery lives in one package. ## Features - `const RateLimitedError = error('RATE_LIMITED', { message, data })` — `throw new RateLimitedError({ data })` works in handlers, middleware, or plain utilities and still reconciles into the typesafe error flow via ORPCError compatibility. The signature mirrors `new ORPCError(code, options)`, and the options are optional: `error('UNAUTHORIZED')` is enough for a bare code. - `instanceof` matches any `ORPCError` with the same code whose data passes the schema, even instances the class did not create, and narrows to `ORPCError<Code, Data>`. Async data schemas throw a descriptive `TypeError` instead of silently misbehaving. - Factories satisfy `ErrorMapItem`, so `[RateLimitedError.code]: RateLimitedError` in `.errors()` renders them in generated OpenAPI documents. - `error`, `ORPCErrorFactory`, and `ORPCErrorFactoryOptions` are re-exported from `@orpc/server`. ## Testing - Unit and type tests for the factory reach 100% statement/branch/function coverage, including `instanceof` edge cases and optional-data constructor signatures. - OpenAPI generator unit test covers factory items registered under their code; a new e2e test asserts the `defined` flag survives the full handler → wire → client round trip on both hono-fetch and node-http transports. ## Docs - New "Error Factory" section (with `instanceof` subsection) in the error handling guide; `ErrorMapItem` and the factory APIs gained JSDoc with links back to it.
1 parent 28d6003 commit 4dc0866

26 files changed

Lines changed: 645 additions & 216 deletions

apps/content/docs/error-handling.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,58 @@ const exampleProcedure = os
130130
`message` and `data` are sent to the client. Do not include sensitive information in either field.
131131
:::
132132

133+
## Error Factory
134+
135+
An error factory lets you define an error once and reuse it anywhere, keeping error handling consistent across your project.
136+
137+
```ts
138+
import { error } from '@orpc/server'
139+
140+
const RateLimitedError = error('RATE_LIMITED', {
141+
/**
142+
* Optional default message, can be overridden when constructing an error.
143+
*/
144+
message: 'You are being rate limited',
145+
/**
146+
* Optional schema used to type and validate the error data.
147+
*/
148+
data: z.object({
149+
retryAfter: z.number(),
150+
}),
151+
})
152+
153+
const procedure = os
154+
.handler(async () => {
155+
throw new RateLimitedError({ data: { retryAfter: 60 } })
156+
})
157+
```
158+
159+
::: tip
160+
You can also register error factories in `.errors`. This makes them part of the [typesafe errors](#typesafe-errors) flow and visible in generated specifications.
161+
162+
```ts
163+
const procedure = os
164+
.errors({
165+
[RateLimitedError.code]: RateLimitedError,
166+
})
167+
```
168+
169+
:::
170+
171+
### `instanceof` Support
172+
173+
An error factory class supports `instanceof` checks with full type narrowing. It matches any `ORPCError` with the same `code` whose `data` passes the schema.
174+
175+
```ts
176+
if (err instanceof RateLimitedError) {
177+
console.log(err.data.retryAfter)
178+
}
179+
```
180+
181+
::: warning
182+
`instanceof` validates `data` synchronously. An error factory with an async data schema throws a `TypeError` when used in an `instanceof` check.
183+
:::
184+
133185
## ORPC Error Codes
134186

135187
By default, oRPC allows any string as an error code and suggests common HTTP codes like `NOT_FOUND` and `UNAUTHORIZED`. You can override this with your own set of allowed error codes for better type safety and consistency.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { ORPCError } from '@orpc/client'
2+
import type { ErrorMap, ORPCErrorFromErrorMap } from './error'
3+
import type { ORPCErrorConstructorMap, ORPCErrorFactory } from './error-factory'
4+
import type { Schema } from './schema'
5+
import z from 'zod'
6+
import { error } from './error-factory'
7+
8+
describe('error factory', () => {
9+
const TestError = error('TEST', {
10+
message: 'default message',
11+
data: z.object({ value: z.number() }),
12+
})
13+
14+
const SimpleError = error('SIMPLE')
15+
16+
const OptionalError = error('OPTIONAL', {
17+
data: z.object({ value: z.number() }).optional(),
18+
})
19+
20+
it('infers the factory type from the options', () => {
21+
expectTypeOf(TestError).toEqualTypeOf<ORPCErrorFactory<'TEST', { value: number }>>()
22+
expectTypeOf(SimpleError).toEqualTypeOf<ORPCErrorFactory<'SIMPLE', unknown>>()
23+
expectTypeOf(OptionalError).toEqualTypeOf<ORPCErrorFactory<'OPTIONAL', { value: number } | undefined>>()
24+
})
25+
26+
it('creates a class that constructs typed ORPCError instances', () => {
27+
expectTypeOf(new TestError({ data: { value: 1 } })).toEqualTypeOf<ORPCError<'TEST', { value: number }>>()
28+
expectTypeOf(new SimpleError()).toEqualTypeOf<ORPCError<'SIMPLE', unknown>>()
29+
30+
// @ts-expect-error - data is required
31+
void new TestError()
32+
// @ts-expect-error - data is required
33+
void new TestError({})
34+
// @ts-expect-error - invalid data
35+
void new TestError({ data: { value: 'invalid' } })
36+
// @ts-expect-error - message must be a string
37+
void new TestError({ data: { value: 1 }, message: 123 })
38+
})
39+
40+
it('options can be omitted when the data schema allows undefined', () => {
41+
expectTypeOf(new OptionalError()).toEqualTypeOf<ORPCError<'OPTIONAL', { value: number } | undefined>>()
42+
expectTypeOf(new OptionalError({ data: { value: 1 } })).toEqualTypeOf<ORPCError<'OPTIONAL', { value: number } | undefined>>()
43+
})
44+
45+
it('exposes typed static code, message, and data', () => {
46+
expectTypeOf(TestError.code).toEqualTypeOf<'TEST'>()
47+
expectTypeOf(TestError.message).toEqualTypeOf<string | undefined>()
48+
expectTypeOf(TestError.data).toEqualTypeOf<Schema<{ value: number }>>()
49+
50+
expectTypeOf(SimpleError.code).toEqualTypeOf<'SIMPLE'>()
51+
expectTypeOf(SimpleError.data).toEqualTypeOf<Schema<unknown>>()
52+
})
53+
54+
it('narrows unknown values via instanceof', () => {
55+
const e = {} as unknown
56+
57+
if (e instanceof TestError) {
58+
expectTypeOf(e).toEqualTypeOf<ORPCError<'TEST', { value: number }>>()
59+
}
60+
})
61+
62+
it('can be used as an error map item with its own code', () => {
63+
const errorMap = {
64+
[TestError.code]: TestError,
65+
[SimpleError.code]: SimpleError,
66+
} satisfies ErrorMap
67+
68+
expectTypeOf<
69+
ORPCErrorFromErrorMap<typeof errorMap>
70+
>().toEqualTypeOf<
71+
| ORPCError<'TEST', { value: number }>
72+
| ORPCError<'SIMPLE', unknown>
73+
>()
74+
})
75+
})
76+
77+
describe('ORPCErrorConstructorMap', () => {
78+
const errorMap = {
79+
BASE: {
80+
data: z.object({ output: z.number() }),
81+
},
82+
OVERRIDE: {
83+
data: z.object({ output: z.number() }).optional(),
84+
},
85+
} satisfies ErrorMap
86+
87+
const constructors = {} as ORPCErrorConstructorMap<typeof errorMap>
88+
89+
it('constructs typed ORPCError instances', () => {
90+
expectTypeOf(constructors.BASE({ data: { output: 123 } })).toEqualTypeOf<ORPCError<'BASE', { output: number }>>()
91+
expectTypeOf(constructors.BASE({ data: { output: 123 }, message: 'custom', cause: 'cause' })).toEqualTypeOf<ORPCError<'BASE', { output: number }>>()
92+
93+
// @ts-expect-error - invalid data
94+
constructors.BASE({ data: { output: '123' } })
95+
// @ts-expect-error - missing data
96+
constructors.BASE()
97+
// @ts-expect-error - code not defined in the map
98+
constructors.NOT_DEFINED()
99+
})
100+
101+
it('options can be omitted when the data schema allows undefined', () => {
102+
expectTypeOf(constructors.OVERRIDE()).toEqualTypeOf<ORPCError<'OVERRIDE', { output: number } | undefined>>()
103+
expectTypeOf(constructors.OVERRIDE({ data: { output: 123 } })).toEqualTypeOf<ORPCError<'OVERRIDE', { output: number } | undefined>>()
104+
})
105+
106+
it('uses the map key as the error code for error factory items', () => {
107+
const FactoryError = error('FACTORY', {
108+
data: z.object({ output: z.number() }),
109+
})
110+
111+
const factoryErrorMap = {
112+
[FactoryError.code]: FactoryError,
113+
} satisfies ErrorMap
114+
115+
const factoryConstructors = {} as ORPCErrorConstructorMap<typeof factoryErrorMap>
116+
117+
expectTypeOf(factoryConstructors.FACTORY({ data: { output: 123 } })).toEqualTypeOf<ORPCError<'FACTORY', { output: number }>>()
118+
})
119+
})
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { Schema } from './schema'
2+
import { ORPCError } from '@orpc/client'
3+
import z from 'zod'
4+
import { createORPCErrorConstructorMap, error } from './error-factory'
5+
6+
describe('error factory', () => {
7+
const dataSchema = z.object({ value: z.number() })
8+
9+
const TestError = error('TEST', {
10+
message: 'default message',
11+
data: dataSchema,
12+
})
13+
14+
const SimpleError = error('SIMPLE')
15+
16+
it('constructs an ORPCError with the defined code, message, and data', () => {
17+
const e = new TestError({ data: { value: 1 } })
18+
19+
expect(e).toBeInstanceOf(ORPCError)
20+
expect(e.code).toBe('TEST')
21+
expect(e.message).toBe('default message')
22+
expect(e.data).toEqual({ value: 1 })
23+
})
24+
25+
it('can override the default message and pass cause', () => {
26+
const e = new TestError({ message: 'custom message', data: { value: 1 }, cause: 'cause' })
27+
28+
expect(e.message).toBe('custom message')
29+
expect(e.cause).toBe('cause')
30+
})
31+
32+
it('can be constructed without options when data schema is not defined', () => {
33+
const e = new SimpleError()
34+
35+
expect(e).toBeInstanceOf(ORPCError)
36+
expect(e.code).toBe('SIMPLE')
37+
expect(e.message).toBe('Simple')
38+
})
39+
40+
it('exposes static code, message, and data so it can be used as an error map item', () => {
41+
expect(TestError.code).toBe('TEST')
42+
expect(TestError.message).toBe('default message')
43+
expect(TestError.data).toBe(dataSchema)
44+
45+
expect(SimpleError.code).toBe('SIMPLE')
46+
expect(SimpleError.message).toBeUndefined()
47+
})
48+
49+
it('defaults static data to a passthrough schema', async () => {
50+
const result = await SimpleError.data['~standard'].validate('anything') as any
51+
52+
expect(result.value).toBe('anything')
53+
expect(result.issues).toBeUndefined()
54+
})
55+
56+
describe('instanceof', () => {
57+
it('matches instances created by the class', () => {
58+
expect(new TestError({ data: { value: 1 } })).toBeInstanceOf(TestError)
59+
expect(new SimpleError()).toBeInstanceOf(SimpleError)
60+
})
61+
62+
it('matches plain ORPCError instances with the same code and valid data', () => {
63+
expect(new ORPCError('TEST', { data: { value: 1 } })).toBeInstanceOf(TestError)
64+
})
65+
66+
it('matches instances of another factory class with the same code and valid data', () => {
67+
const OtherTestError = error('TEST', { data: dataSchema })
68+
69+
expect(new OtherTestError({ data: { value: 1 } })).toBeInstanceOf(TestError)
70+
expect(new TestError({ data: { value: 1 } })).toBeInstanceOf(OtherTestError)
71+
})
72+
73+
it('matches any data when data schema is not defined', () => {
74+
expect(new ORPCError('SIMPLE')).toBeInstanceOf(SimpleError)
75+
expect(new ORPCError('SIMPLE', { data: 'anything' })).toBeInstanceOf(SimpleError)
76+
})
77+
78+
it('rejects non-ORPCError values', () => {
79+
expect(new Error('TEST')).not.toBeInstanceOf(TestError)
80+
expect('TEST').not.toBeInstanceOf(TestError)
81+
expect(null).not.toBeInstanceOf(TestError)
82+
})
83+
84+
it('rejects ORPCError instances with a different code', () => {
85+
expect(new ORPCError('ANOTHER', { data: { value: 1 } })).not.toBeInstanceOf(TestError)
86+
})
87+
88+
it('rejects ORPCError instances with invalid data', () => {
89+
expect(new ORPCError('TEST')).not.toBeInstanceOf(TestError)
90+
expect(new ORPCError('TEST', { data: { value: 'invalid' } })).not.toBeInstanceOf(TestError)
91+
})
92+
93+
it('throws when data schema is async', () => {
94+
const AsyncError = error('ASYNC', {
95+
data: {
96+
'~standard': {
97+
version: 1,
98+
vendor: 'test',
99+
validate: async value => ({ value }),
100+
},
101+
} satisfies Schema<unknown>,
102+
})
103+
104+
expect(() => new ORPCError('ASYNC') instanceof AsyncError).toThrow(
105+
'Cannot use `instanceof` with error factory "ASYNC": its data schema validates asynchronously is not supported.',
106+
)
107+
})
108+
})
109+
})
110+
111+
describe('createORPCErrorConstructorMap', () => {
112+
const errorMap = {
113+
BAD_GATEWAY: {
114+
message: 'default message',
115+
data: z.object({ output: z.number() }),
116+
},
117+
118+
WITH_ERROR_FACTORY: error('WITH_ERROR_FACTORY', {
119+
message: 'factory message',
120+
data: z.object({ output: z.number() }),
121+
}),
122+
}
123+
124+
const constructors = createORPCErrorConstructorMap(errorMap)
125+
126+
it('works', () => {
127+
const e = constructors.BAD_GATEWAY({ data: { output: 123 }, cause: 'cause' })
128+
129+
expect(e).toBeInstanceOf(ORPCError)
130+
expect(e.code).toEqual('BAD_GATEWAY')
131+
expect(e.defined).toBe(true)
132+
expect(e.inferable).toBe(true)
133+
expect(e.message).toBe('default message')
134+
expect(e.data).toEqual({ output: 123 })
135+
expect(e.cause).toBe('cause')
136+
})
137+
138+
it('works with error factory item registered under its code', () => {
139+
const e = constructors.WITH_ERROR_FACTORY({ data: { output: 123 } })
140+
141+
expect(e).toBeInstanceOf(ORPCError)
142+
expect(e).toBeInstanceOf(errorMap.WITH_ERROR_FACTORY)
143+
expect(e.code).toEqual('WITH_ERROR_FACTORY')
144+
expect(e.defined).toBe(true)
145+
expect(e.inferable).toBe(true)
146+
expect(e.message).toBe('factory message')
147+
expect(e.data).toEqual({ output: 123 })
148+
})
149+
150+
it('can override message', () => {
151+
expect(
152+
constructors.BAD_GATEWAY({ message: 'custom message', data: { output: 123 } }).message,
153+
).toBe('custom message')
154+
})
155+
156+
it('fallback normal error when access undefined code', () => {
157+
// @ts-expect-error - invalid access
158+
const e = constructors.ANY_THING({ data: 'DATA', message: 'MESSAGE', cause: 'cause' })
159+
160+
expect(e).toBeInstanceOf(ORPCError)
161+
expect(e.code).toEqual('ANY_THING')
162+
expect(e.defined).toBe(false)
163+
expect(e.inferable).toBe(false)
164+
expect(e.message).toBe('MESSAGE')
165+
expect(e.data).toEqual('DATA')
166+
expect(e.cause).toBe('cause')
167+
})
168+
169+
it('works with no options', () => {
170+
// @ts-expect-error - missing data
171+
const e = constructors.BAD_GATEWAY()
172+
173+
expect(e).toBeInstanceOf(ORPCError)
174+
expect(e.code).toEqual('BAD_GATEWAY')
175+
expect(e.message).toBe('default message')
176+
expect(e.data).toBeUndefined()
177+
expect(e.defined).toBe(true)
178+
expect(e.inferable).toBe(true)
179+
})
180+
181+
it('not proxy when access with symbol', () => {
182+
// @ts-expect-error - invalid access
183+
expect(constructors[Symbol('something')]).toBeUndefined()
184+
})
185+
186+
it('in operator works', () => {
187+
expect('BAD_GATEWAY' in constructors).toBe(true)
188+
expect('ANY_THING' in constructors).toBe(false)
189+
})
190+
})

0 commit comments

Comments
 (0)