From e912b10d185c382cdcebdfb4144c4e32710ec396 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 2 Aug 2026 20:55:17 +0700 Subject: [PATCH] feat(contract): validate data in error factory constructor The error factory constructor now validates data against the schema: invalid data throws a ValidationError and the validated schema output is what gets stored on the error. Since both the constructor and instanceof now validate synchronously, the sync-schema requirement is factory-wide; the async-schema TypeError is unified into one shared helper and the docs warning moved from the instanceof subsection to the main Error Factory section. --- apps/content/docs/error-handling.md | 5 +- packages/contract/src/error-factory.test.ts | 40 ++++++++++++- packages/contract/src/error-factory.ts | 62 ++++++++++++--------- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/apps/content/docs/error-handling.md b/apps/content/docs/error-handling.md index aa5d962b9..45170bebc 100644 --- a/apps/content/docs/error-handling.md +++ b/apps/content/docs/error-handling.md @@ -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(), @@ -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. diff --git a/packages/contract/src/error-factory.test.ts b/packages/contract/src/error-factory.test.ts index 5f213fbc4..40b80f7dd 100644 --- a/packages/contract/src/error-factory.test.ts +++ b/packages/contract/src/error-factory.test.ts @@ -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', () => { @@ -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, + }) + + 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') @@ -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.', ) }) }) diff --git a/packages/contract/src/error-factory.ts b/packages/contract/src/error-factory.ts index a62d58100..ac7b556dc 100644 --- a/packages/contract/src/error-factory.ts +++ b/packages/contract/src/error-factory.ts @@ -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 { /** * Optional schema used to type and validate the error data. + * Must be a synchronous schema. */ data?: Schema @@ -29,12 +31,6 @@ export interface ORPCErrorFactory 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', { @@ -58,24 +54,48 @@ export interface ORPCErrorFactory 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( code: TCode, - { data, message }: ORPCErrorFactoryOptions = {}, + { data: dataSchema, message }: ORPCErrorFactoryOptions = {}, ): ORPCErrorFactory { + const validateData = (schema: Schema, 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 { static code: TCode = code - static data: Schema = data ?? type() + static data: Schema = dataSchema ?? type() static message: string | undefined = message constructor(...rest: MaybeOptionalOptions>) { 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 { @@ -87,18 +107,8 @@ export function error( 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