Skip to content

Commit 240215c

Browse files
authored
feat(contract): validate data in error factory constructor (#1765)
The error factory constructor now validates `data` against the schema instead of only validating during `instanceof` checks. Invalid data throws a `ValidationError` (with `issues` and `invalidData`, same class used by input/output validation), and the validated schema output is what gets stored on the error - so schema transforms like zod's unknown-key stripping now apply to constructed errors. ## Behavior - Since both the constructor and `instanceof` validate synchronously, the sync-schema requirement is now factory-wide: an async data schema throws the same `TypeError` from either path, with a unified message. - Factories without a data schema are unchanged - no validation runs. ## Docs - The sync-schema warning moved from the `instanceof` subsection to the main Error Factory section and now also covers the constructor's `ValidationError` behavior; JSDoc updated to match. ## Testing - New constructor cases: validated value is stored (proven via zod key stripping), invalid/missing data throws `ValidationError`, async schemas throw in the constructor. - Contract, server procedure-client, OpenAPI generator, and e2e data-transfer suites pass; `pnpm type:check` and eslint are clean.
1 parent 725972b commit 240215c

3 files changed

Lines changed: 76 additions & 31 deletions

File tree

apps/content/docs/error-handling.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ const RateLimitedError = error('RATE_LIMITED', {
144144
message: 'You are being rate limited',
145145
/**
146146
* Optional schema used to type and validate the error data.
147+
* Must be a synchronous schema.
147148
*/
148149
data: z.object({
149150
retryAfter: z.number(),
@@ -178,10 +179,6 @@ if (err instanceof RateLimitedError) {
178179
}
179180
```
180181

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-
185182
## ORPC Error Codes
186183

187184
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.

packages/contract/src/error-factory.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Schema } from './schema'
22
import { ORPCError } from '@orpc/client'
33
import z from 'zod'
4+
import { ValidationError } from './error'
45
import { createORPCErrorConstructorMap, error } from './error-factory'
56

67
describe('error factory', () => {
@@ -37,6 +38,43 @@ describe('error factory', () => {
3738
expect(e.message).toBe('Simple')
3839
})
3940

41+
it('validates data in the constructor and stores the validated value', () => {
42+
// zod strips unknown keys, proving the stored data is the schema output
43+
const e = new TestError({ data: { value: 1, extra: 'stripped' } as any })
44+
45+
expect(e.data).toEqual({ value: 1 })
46+
})
47+
48+
it('throws a ValidationError when constructed with invalid data', () => {
49+
expect(() => new TestError({ data: { value: 'invalid' } as any })).toThrowError(
50+
expect.objectContaining({
51+
constructor: ValidationError,
52+
message: 'Error factory "TEST" data validation failed',
53+
issues: expect.any(Array),
54+
invalidData: { value: 'invalid' },
55+
}),
56+
)
57+
58+
// @ts-expect-error - data is required
59+
expect(() => new TestError()).toThrow(ValidationError)
60+
})
61+
62+
it('throws in the constructor when data schema is async', () => {
63+
const AsyncError = error('ASYNC', {
64+
data: {
65+
'~standard': {
66+
version: 1,
67+
vendor: 'test',
68+
validate: async value => ({ value }),
69+
},
70+
} satisfies Schema<unknown>,
71+
})
72+
73+
expect(() => new AsyncError({ data: 'anything' })).toThrow(
74+
'Error factory "ASYNC" does not support async data schemas.',
75+
)
76+
})
77+
4078
it('exposes static code, message, and data so it can be used as an error map item', () => {
4179
expect(TestError.code).toBe('TEST')
4280
expect(TestError.message).toBe('default message')
@@ -102,7 +140,7 @@ describe('error factory', () => {
102140
})
103141

104142
expect(() => new ORPCError('ASYNC') instanceof AsyncError).toThrow(
105-
'Cannot use `instanceof` with error factory "ASYNC": its data schema validates asynchronously is not supported.',
143+
'Error factory "ASYNC" does not support async data schemas.',
106144
)
107145
})
108146
})

packages/contract/src/error-factory.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import type { AnySchema, InferSchemaInput, Schema } from './schema'
55

66
import { ORPCError } from '@orpc/client'
77
import { resolveMaybeOptionalOptions } from '@orpc/shared'
8+
import { ValidationError } from './error'
89
import { type } from './schema-utils'
910

1011
export interface ORPCErrorFactoryOptions<TData> {
1112
/**
1213
* Optional schema used to type and validate the error data.
14+
* Must be a synchronous schema.
1315
*/
1416
data?: Schema<TData>
1517

@@ -29,12 +31,6 @@ export interface ORPCErrorFactory<TCode extends ORPCErrorCode, TData> extends Er
2931
* Creates a reusable error class ({@link ORPCErrorFactory}) for the given code,
3032
* default message, and data schema.
3133
*
32-
* The returned class extends {@link ORPCError}, so it can be thrown anywhere
33-
* and used directly as an error map item. Its `instanceof` matches any
34-
* `ORPCError` with the same code whose data passes the schema, even instances
35-
* not created by the class - but throws a `TypeError` when the data schema
36-
* validates asynchronously.
37-
*
3834
* @example
3935
* ```ts
4036
* const RateLimitedError = error('RATE_LIMITED', {
@@ -58,24 +54,48 @@ export interface ORPCErrorFactory<TCode extends ORPCErrorCode, TData> extends Er
5854
* ```
5955
*
6056
* @see {@link https://orpc.dev/docs/error-handling#error-factory Error Factory Docs}
61-
*
62-
* @param code - The error code carried by every error the factory creates.
63-
* @param options - Optional data schema and default message for the created errors.
64-
* @param options.data - Schema used to type and validate the error data.
65-
* @param options.message - Default message, can be overridden when constructing an error.
6657
*/
6758
export function error<TCode extends ORPCErrorCode, TData = unknown>(
6859
code: TCode,
69-
{ data, message }: ORPCErrorFactoryOptions<TData> = {},
60+
{ data: dataSchema, message }: ORPCErrorFactoryOptions<TData> = {},
7061
): ORPCErrorFactory<TCode, TData> {
62+
const validateData = (schema: Schema<TData>, value: unknown) => {
63+
const result = schema['~standard'].validate(value)
64+
65+
if (result instanceof Promise) {
66+
throw new TypeError(
67+
`Error factory "${code}" does not support async data schemas.`,
68+
)
69+
}
70+
71+
return result
72+
}
73+
7174
return class extends ORPCError<TCode, TData> {
7275
static code: TCode = code
73-
static data: Schema<TData> = data ?? type<any>()
76+
static data: Schema<TData> = dataSchema ?? type<any>()
7477
static message: string | undefined = message
7578

7679
constructor(...rest: MaybeOptionalOptions<ORPCErrorOptions<TData>>) {
7780
const options = resolveMaybeOptionalOptions(rest)
78-
super(code, { message, ...options })
81+
82+
let data = options.data
83+
84+
if (dataSchema) {
85+
const result = validateData(dataSchema, options.data)
86+
87+
if (result.issues) {
88+
throw new ValidationError({
89+
message: `Error factory "${code}" data validation failed`,
90+
issues: result.issues,
91+
invalidData: options.data,
92+
})
93+
}
94+
95+
data = result.value
96+
}
97+
98+
super(code, { message, ...options, data })
7999
}
80100

81101
static override[Symbol.hasInstance](instance: unknown): boolean {
@@ -87,18 +107,8 @@ export function error<TCode extends ORPCErrorCode, TData = unknown>(
87107
return false
88108
}
89109

90-
if (data) {
91-
const result = data['~standard'].validate(instance.data)
92-
93-
if (result instanceof Promise) {
94-
throw new TypeError(
95-
`Cannot use \`instanceof\` with error factory "${code}": its data schema validates asynchronously is not supported.`,
96-
)
97-
}
98-
99-
if (result.issues) {
100-
return false
101-
}
110+
if (dataSchema && validateData(dataSchema, instance.data).issues) {
111+
return false
102112
}
103113

104114
return true

0 commit comments

Comments
 (0)