Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions apps/content/docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const RateLimitedError = error('RATE_LIMITED', {
message: 'You are being rate limited',
/**
* Optional schema used to type and validate the error data.
* Must be a synchronous schema.
*/
data: z.object({
retryAfter: z.number(),
Expand Down Expand Up @@ -178,10 +179,6 @@ if (err instanceof RateLimitedError) {
}
```

::: warning
`instanceof` validates `data` synchronously. An error factory with an async data schema throws a `TypeError` when used in an `instanceof` check.
:::

## ORPC Error Codes

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.
Expand Down
40 changes: 39 additions & 1 deletion packages/contract/src/error-factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Schema } from './schema'
import { ORPCError } from '@orpc/client'
import z from 'zod'
import { ValidationError } from './error'
import { createORPCErrorConstructorMap, error } from './error-factory'

describe('error factory', () => {
Expand Down Expand Up @@ -37,6 +38,43 @@ describe('error factory', () => {
expect(e.message).toBe('Simple')
})

it('validates data in the constructor and stores the validated value', () => {
// zod strips unknown keys, proving the stored data is the schema output
const e = new TestError({ data: { value: 1, extra: 'stripped' } as any })

expect(e.data).toEqual({ value: 1 })
})

it('throws a ValidationError when constructed with invalid data', () => {
expect(() => new TestError({ data: { value: 'invalid' } as any })).toThrowError(
expect.objectContaining({
constructor: ValidationError,
message: 'Error factory "TEST" data validation failed',
issues: expect.any(Array),
invalidData: { value: 'invalid' },
}),
)

// @ts-expect-error - data is required
expect(() => new TestError()).toThrow(ValidationError)
})

it('throws in the constructor when data schema is async', () => {
const AsyncError = error('ASYNC', {
data: {
'~standard': {
version: 1,
vendor: 'test',
validate: async value => ({ value }),
},
} satisfies Schema<unknown>,
})

expect(() => new AsyncError({ data: 'anything' })).toThrow(
'Error factory "ASYNC" does not support async data schemas.',
)
})

it('exposes static code, message, and data so it can be used as an error map item', () => {
expect(TestError.code).toBe('TEST')
expect(TestError.message).toBe('default message')
Expand Down Expand Up @@ -102,7 +140,7 @@ describe('error factory', () => {
})

expect(() => new ORPCError('ASYNC') instanceof AsyncError).toThrow(
'Cannot use `instanceof` with error factory "ASYNC": its data schema validates asynchronously is not supported.',
'Error factory "ASYNC" does not support async data schemas.',
)
})
})
Expand Down
62 changes: 36 additions & 26 deletions packages/contract/src/error-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type { AnySchema, InferSchemaInput, Schema } from './schema'

import { ORPCError } from '@orpc/client'
import { resolveMaybeOptionalOptions } from '@orpc/shared'
import { ValidationError } from './error'
import { type } from './schema-utils'

export interface ORPCErrorFactoryOptions<TData> {
/**
* Optional schema used to type and validate the error data.
* Must be a synchronous schema.
*/
data?: Schema<TData>

Expand All @@ -29,12 +31,6 @@ export interface ORPCErrorFactory<TCode extends ORPCErrorCode, TData> extends Er
* Creates a reusable error class ({@link ORPCErrorFactory}) for the given code,
* default message, and data schema.
*
* The returned class extends {@link ORPCError}, so it can be thrown anywhere
* and used directly as an error map item. Its `instanceof` matches any
* `ORPCError` with the same code whose data passes the schema, even instances
* not created by the class - but throws a `TypeError` when the data schema
* validates asynchronously.
*
* @example
* ```ts
* const RateLimitedError = error('RATE_LIMITED', {
Expand All @@ -58,24 +54,48 @@ export interface ORPCErrorFactory<TCode extends ORPCErrorCode, TData> extends Er
* ```
*
* @see {@link https://orpc.dev/docs/error-handling#error-factory Error Factory Docs}
*
* @param code - The error code carried by every error the factory creates.
* @param options - Optional data schema and default message for the created errors.
* @param options.data - Schema used to type and validate the error data.
* @param options.message - Default message, can be overridden when constructing an error.
*/
export function error<TCode extends ORPCErrorCode, TData = unknown>(
code: TCode,
{ data, message }: ORPCErrorFactoryOptions<TData> = {},
{ data: dataSchema, message }: ORPCErrorFactoryOptions<TData> = {},
): ORPCErrorFactory<TCode, TData> {
const validateData = (schema: Schema<TData>, value: unknown) => {
const result = schema['~standard'].validate(value)

if (result instanceof Promise) {
throw new TypeError(
`Error factory "${code}" does not support async data schemas.`,
)
}

return result
}

return class extends ORPCError<TCode, TData> {
static code: TCode = code
static data: Schema<TData> = data ?? type<any>()
static data: Schema<TData> = dataSchema ?? type<any>()
static message: string | undefined = message

constructor(...rest: MaybeOptionalOptions<ORPCErrorOptions<TData>>) {
const options = resolveMaybeOptionalOptions(rest)
super(code, { message, ...options })

let data = options.data

if (dataSchema) {
const result = validateData(dataSchema, options.data)

if (result.issues) {
throw new ValidationError({
message: `Error factory "${code}" data validation failed`,
issues: result.issues,
invalidData: options.data,
})
}

data = result.value
}

super(code, { message, ...options, data })
}

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

if (data) {
const result = data['~standard'].validate(instance.data)

if (result instanceof Promise) {
throw new TypeError(
`Cannot use \`instanceof\` with error factory "${code}": its data schema validates asynchronously is not supported.`,
)
}

if (result.issues) {
return false
}
if (dataSchema && validateData(dataSchema, instance.data).issues) {
return false
}

return true
Expand Down
Loading