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
2 changes: 2 additions & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export default withMermaid(defineConfig({
{ text: 'CORS', link: '/docs/plugins/cors' },
{ text: 'Request Headers', link: '/docs/plugins/request-headers' },
{ text: 'Response Headers', link: '/docs/plugins/response-headers' },
{ text: 'Response Validation', link: '/docs/plugins/response-validation' },
{ text: 'Hibernation', link: '/docs/plugins/hibernation' },
{ text: 'Dedupe Requests', link: '/docs/plugins/dedupe-requests' },
{ text: 'Batch Requests', link: '/docs/plugins/batch-requests' },
Expand Down Expand Up @@ -275,6 +276,7 @@ export default withMermaid(defineConfig({
collapsed: true,
items: [
{ text: 'Customizing Error Response', link: '/docs/openapi/advanced/customizing-error-response' },
{ text: 'Expanding Type Support for OpenAPI Link', link: '/docs/openapi/advanced/expanding-type-support-for-openapi-link' },
{ text: 'OpenAPI JSON Serializer', link: '/docs/openapi/advanced/openapi-json-serializer' },
{ text: 'Redirect Response', link: '/docs/openapi/advanced/redirect-response' },
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: Expanding Type Support for OpenAPI Link
description: Learn how to extend OpenAPILink to support additional data types beyond JSON's native capabilities using the Response Validation Plugin and schema coercion.
---

# Expanding Type Support for OpenAPI Link

This guide will show you how to extend [OpenAPILink](/docs/openapi/client/openapi-link) to support additional data types beyond JSON's native capabilities using the [Response Validation Plugin](/docs/plugins/response-validation).

## How It Works

To enable this functionality, you need to customize your output schema with proper coercion logic.

**Why?** OpenAPI response data only represents JSON's native capabilities. We use schema coercion logic in output schemas to convert the data to the desired type.

::: warning
Beyond JSON limitations, outputs containing `Blob` or `File` types (outside the root level) also face [Bracket Notation](/docs/openapi/bracket-notation#limitations) limitations.
:::

```ts
const contract = oc.output(z.object({
date: z.coerce.date(), // [!code highlight]
bigint: z.coerce.bigint(), // [!code highlight]
}))

const procedure = implement(contract).handler(() => ({
date: new Date(),
bigint: 123n,
}))
```

On the client side, you'll receive the output like this:

```ts
const beforeValidation = {
date: '2025-09-01T07:24:39.000Z',
bigint: '123'
}
```

Since your output schema contains coercion logic, the Response Validation Plugin will convert the data to the desired type after validation.

```ts
const afterValidation = {
date: new Date('2025-09-01T07:24:39.000Z'),
bigint: 123n
}
```

::: warning
To support more types than those in [OpenAPI Handler](/docs/openapi/openapi-handler#supported-data-types), you must first extend the [OpenAPI JSON Serializer](/docs/openapi/advanced/openapi-json-serializer) first.
:::

## Setup

After understanding how it works and expanding output schemas with coercion logic, you only need to set up the [Response Validation Plugin](/docs/plugins/response-validation) and remove the `JsonifiedClient` wrapper.

```diff
import type { ContractRouterClient } from '@orpc/contract'
import { createORPCClient } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { ResponseValidationPlugin } from '@orpc/contract/plugins'

const link = new OpenAPILink(contract, {
url: 'http://localhost:3000/api',
plugins: [
+ new ResponseValidationPlugin(contract),
]
})

-const client: JsonifiedClient<ContractRouterClient<typeof contract>> = createORPCClient(link)
+const client: ContractRouterClient<typeof contract> = createORPCClient(link)
```
2 changes: 1 addition & 1 deletion apps/content/docs/openapi/client/openapi-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const client: JsonifiedClient<ContractRouterClient<typeof contract>> = createORP
```

:::warning
Wrap your client with `JsonifiedClient` to ensure it accurately reflects the server responses.
Due to JSON limitations, you must wrap your client with `JsonifiedClient` to ensure type safety. Alternatively, follow the [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link) guide to preserve original types without the wrapper.
:::

## Limitations
Expand Down
4 changes: 4 additions & 0 deletions apps/content/docs/plugins/client-retry.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const link = new RPCLink<ORPCClientContext>({
const client: RouterClient<typeof router, ORPCClientContext> = createORPCClient(link)
```

::: info
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
:::

## Usage

```ts twoslash
Expand Down
52 changes: 52 additions & 0 deletions apps/content/docs/plugins/response-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
title: Response Validation Plugin
description: A plugin that validates server responses against the contract schema to ensure that the data returned from your server matches the expected types defined in your contract.
---

# Response Validation Plugin

The **Response Validation Plugin** validates server responses against your contract schema, ensuring that data returned from your server matches the expected types defined in your contract.

::: info
This plugin is best suited for [Contract-First Development](/docs/contract-first/define-contract). [Minified Contract](/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client) is **not supported** because it removes the schema from the contract.
:::

## Setup

```ts twoslash
import { contract } from './shared/planet'
import { createORPCClient } from '@orpc/client'
import type { ContractRouterClient } from '@orpc/contract'
// ---cut---
import { RPCLink } from '@orpc/client/fetch'
import { ResponseValidationPlugin } from '@orpc/contract/plugins'

const link = new RPCLink({
url: 'http://localhost:3000/rpc',
plugins: [
new ResponseValidationPlugin(contract),
],
})

const client: ContractRouterClient<typeof contract> = createORPCClient(link)
```

::: info
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
:::

## Limitations

Schemas that transform data into different types than the expected schema types are not supported.

**Why?** Consider this example schema that accepts a `number` and transforms it into a `string` after validation:

```ts
const unsupported = z.number().transform(value => value.toString())
```

When the server validates output, it transforms the `number` into a `string`. The client receives a `string`, but the `string` no longer matches the original schema, causing validation to fail.

## Advanced Usage

Beyond response validation, this plugin also serves special purposes such as [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link).
8 changes: 7 additions & 1 deletion packages/contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./plugins": {
"types": "./dist/plugins/index.d.mts",
"import": "./dist/plugins/index.mjs",
"default": "./dist/plugins/index.mjs"
Comment thread
dinwwwh marked this conversation as resolved.
}
}
},
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./plugins": "./src/plugins/index.ts"
},
"files": [
"dist"
Expand Down
73 changes: 72 additions & 1 deletion packages/contract/src/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ErrorMap } from './error'
import { ORPCError } from '@orpc/client'
import z from 'zod'
import { baseErrorMap } from '../tests/shared'
import { mergeErrorMap, ValidationError } from './error'
import { mergeErrorMap, validateORPCError, ValidationError } from './error'

it('validationError', () => {
const error = new ValidationError({ message: 'message', issues: [{ message: 'message' }] })
Expand All @@ -13,3 +16,71 @@ it('mergeErrorMap', () => {
{ OVERRIDE: {}, INVALID: {}, BASE: baseErrorMap.BASE },
)
})

describe('validateORPCError', () => {
const errors: ErrorMap = {
BAD_GATEWAY: {
data: z.object({
value: z.string().transform(v => Number.parseInt(v)),
}),
},
CONFLICT: {
status: 483,
},
}

it('ignore not-match errors when defined=false', async () => {
const e1 = new ORPCError('BAD_GATEWAY', { status: 501, data: { value: '123' } })
expect(await validateORPCError(errors, e1)).toBe(e1)

const e2 = new ORPCError('NOT_FOUND')
expect(await validateORPCError(errors, e2)).toBe(e2)

const e3 = new ORPCError('BAD_GATEWAY', { data: 'invalid' })
expect(await validateORPCError(errors, e3)).toBe(e3)

const e4 = new ORPCError('CONFLICT')
expect(await validateORPCError(errors, e4)).toBe(e4)
})

it('modify not-match errors when defined=true', async () => {
const e1 = new ORPCError('BAD_GATEWAY', { defined: true, status: 501 })
const v1 = await validateORPCError(errors, e1)
expect(v1).not.toBe(e1)
expect({ ...v1 }).toEqual({ ...e1, defined: false })

const e2 = new ORPCError('NOT_FOUND', { defined: true })
const v2 = await validateORPCError(errors, e2)
expect(v2).not.toBe(e2)
expect({ ...v2 }).toEqual({ ...e2, defined: false })

const e3 = new ORPCError('BAD_GATEWAY', { defined: true, data: 'invalid' })
const v3 = await validateORPCError(errors, e3)
expect(v3).not.toBe(e3)
expect({ ...v3 }).toEqual({ ...e3, defined: false })

const e4 = new ORPCError('CONFLICT', { defined: true })
const v4 = await validateORPCError(errors, e4)
expect(v4).not.toBe(e4)
expect({ ...v4 }).toEqual({ ...e4, defined: false })
})

it('ignore match errors when defined=true and data schema is undefined', async () => {
const e1 = new ORPCError('CONFLICT', { defined: true, status: 483 })
expect(await validateORPCError(errors, e1)).toBe(e1)
})

it('return new error when defined=true and data schema is undefined with match error', async () => {
const e1 = new ORPCError('CONFLICT', { status: 483 })
const v1 = await validateORPCError(errors, e1)
expect(v1).not.toBe(e1)
expect({ ...v1 }).toEqual({ ...e1, defined: true })
})

it('return new with defined=true and validated data with match errors', async () => {
const e1 = new ORPCError('BAD_GATEWAY', { data: { value: '123' } })
const v1 = await validateORPCError(errors, e1)
expect(v1).not.toBe(e1)
expect({ ...v1 }).toEqual({ ...e1, defined: true, data: { value: 123 } })
})
})
30 changes: 29 additions & 1 deletion packages/contract/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ORPCError, ORPCErrorCode } from '@orpc/client'
import type { ORPCErrorCode } from '@orpc/client'
import type { ThrowableError } from '@orpc/shared'
import type { AnySchema, InferSchemaOutput, Schema, SchemaIssue } from './schema'
import { fallbackORPCErrorStatus, ORPCError } from '@orpc/client'

export interface ValidationErrorOptions extends ErrorOptions {
message: string
Expand Down Expand Up @@ -53,3 +54,30 @@ export type ORPCErrorFromErrorMap<TErrorMap extends ErrorMap> = {
}[keyof TErrorMap]

export type ErrorFromErrorMap<TErrorMap extends ErrorMap> = ORPCErrorFromErrorMap<TErrorMap> | ThrowableError

export async function validateORPCError(map: ErrorMap, error: ORPCError<any, any>): Promise<ORPCError<string, unknown>> {
const { code, status, message, data, cause, defined } = error
const config = map?.[error.code]

if (!config || fallbackORPCErrorStatus(error.code, config.status) !== error.status) {
return defined
? new ORPCError(code, { defined: false, status, message, data, cause })
: error
}

if (!config.data) {
return defined
? error
: new ORPCError(code, { defined: true, status, message, data, cause })
}

const validated = await config.data['~standard'].validate(error.data)

if (validated.issues) {
return defined
? new ORPCError(code, { defined: false, status, message, data, cause })
: error
}

return new ORPCError(code, { defined: true, status, message, data: validated.value, cause })
}
Comment thread
dinwwwh marked this conversation as resolved.
3 changes: 3 additions & 0 deletions packages/contract/src/plugins/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
it('exports something', async () => {
expect(await import('./index')).toHaveProperty('ResponseValidationPlugin')
})
1 change: 1 addition & 0 deletions packages/contract/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './response-validation'
Loading
Loading