orpc-contract

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

oRPC Contract-First

oRPC 契约优先开发

Contract-first oRPC splits an API into two artifacts: a contract (schemas, errors, metadata, no business logic) defined with
oc
from
@orpc/contract
, and an implementation built from it with
implement
from
@orpc/server
. Server and client both depend on the contract, never on each other, so the API shape can live in its own package, be reviewed on its own, and ship to consumers as a typed SDK. Prefer it when server and client are separate packages or teams, when the shape starts from an existing OpenAPI spec, or when publishing a client to npm. Stay with the
os
-first flow when one codebase holds both sides and the client can import the router type directly; converting later is cheap because a plain router already works as a router contract (resolve lazy routers with
unlazyRouter
first).
This skill targets oRPC v2. The
orpc
skill carries the v2 install and version check plus core builder, middleware, serving, and client concepts. Pretrained oRPC knowledge describes v1 and is often wrong for v2: when unsure of any API below, fetch its docs page first (see Full documentation).
契约优先的 oRPC 将 API 拆分为两个构件:一个是使用
@orpc/contract
中的
oc
定义的契约(包含模式、错误信息、元数据,无业务逻辑),另一个是通过
@orpc/server
中的
implement
基于契约构建的实现。服务端和客户端均依赖契约,而非彼此依赖,因此 API 结构可以独立存放在单独的包中,单独进行审核,并作为类型化 SDK 交付给使用者。当服务端与客户端属于不同包或团队、API 结构基于现有 OpenAPI 规范构建,或是需要将客户端发布至 npm 时,推荐使用此方式。若服务端与客户端在同一代码库中,且客户端可直接导入路由类型,则可采用
os
优先流程;后续转换成本很低,因为普通路由本身即可作为路由契约(转换前需先用
unlazyRouter
解析延迟加载的路由)。
本技能针对 oRPC v2 版本。
orpc
技能包含 v2 的安装、版本检查,以及核心构建、中间件、服务部署和客户端相关概念。预训练的 oRPC 知识基于 v1 版本,对 v2 往往不适用:若对以下任何 API 存在疑问,请优先查阅其文档页面(参见完整文档)。

Define the contract with oc

使用 oc 定义契约

Every chain is optional and each call returns a new instance, so share base contracts freely. A contract has no
.handler
. Zod, Valibot, ArkType, and any other Standard Schema library work.
ts
import { oc } from '@orpc/contract'
import * as z from 'zod'

export const contract = {
  planet: {
    list: oc
      .output(z.array(z.object({ id: z.number(), name: z.string() }))),
    find: oc
      .errors({ NOT_FOUND: {} })
      .input(z.object({ id: z.number() }))
      .output(z.object({ id: z.number(), name: z.string() })),
  },
}
  • Always define
    .output
    : without it clients infer the output as
    unknown
    .
  • A router contract is a plain object mapping keys to procedure contracts or nested objects. Avoid the keys
    then
    ,
    bind
    ,
    valueOf
    ,
    toString
    ,
    toJSON
    .
  • Attach shared metadata to a whole subtree with
    oc.meta(someMeta).router({...})
    .
  • .errors({ NOT_FOUND: {} })
    declares typesafe errors; implementations throw them via
    errors.NOT_FOUND()
    and clients infer their shapes.
  • Repeated
    .input
    /
    .output
    calls stack schemas instead of replacing them: object input schemas compose into one flat value, output schemas pipe. Use this to extend a base contract without repeating fields.
  • Schema-library-free contracts use the
    type
    utility from
    @orpc/contract
    :
    oc.input(type<{ value: number }>())
    , optionally with a mapping function as
    type<Input, Output>(fn)
    .
  • REST routes attach via
    .meta(openapi({ method: 'GET', path: '/planets/{id}' }))
    from
    @orpc/openapi
    , exactly as on
    os
    ; routing rules,
    prefix
    , and spec generation belong to the
    orpc-openapi
    skill.
Infer types with
InferRouterContractInputs
,
InferRouterContractOutputs
, and
InferRouterContractErrors
from
@orpc/contract
.
每个链式调用都是可选的,且每次调用都会返回新实例,因此可以自由共享基础契约。契约不包含
.handler
。Zod、Valibot、ArkType 及其他任何标准模式库均适用。
ts
import { oc } from '@orpc/contract'
import * as z from 'zod'

export const contract = {
  planet: {
    list: oc
      .output(z.array(z.object({ id: z.number(), name: z.string() }))),
    find: oc
      .errors({ NOT_FOUND: {} })
      .input(z.object({ id: z.number() }))
      .output(z.object({ id: z.number(), name: z.string() })),
  },
}
  • 务必定义
    .output
    :否则客户端会将输出推断为
    unknown
  • 路由契约是一个普通对象,键对应过程契约或嵌套对象。避免使用
    then
    bind
    valueOf
    toString
    toJSON
    作为键名。
  • 使用
    oc.meta(someMeta).router({...})
    为整个子树附加共享元数据。
  • .errors({ NOT_FOUND: {} })
    声明类型安全的错误;实现时可通过
    errors.NOT_FOUND()
    抛出错误,客户端会自动推断错误结构。
  • 重复调用
    .input
    /
    .output
    会堆叠模式而非替换:对象输入模式会组合成一个扁平值,输出模式会按顺序执行。可利用此特性扩展基础契约,无需重复字段。
  • 无需模式库的契约可使用
    @orpc/contract
    中的
    type
    工具:
    oc.input(type<{ value: number }>())
    ,也可选择传入映射函数
    type<Input, Output>(fn)
  • REST 路由可通过
    @orpc/openapi
    中的
    .meta(openapi({ method: 'GET', path: '/planets/{id}' }))
    附加,与
    os
    中的用法完全一致;路由规则、
    prefix
    和规范生成属于
    orpc-openapi
    技能的范畴。
可使用
@orpc/contract
中的
InferRouterContractInputs
InferRouterContractOutputs
InferRouterContractErrors
推断类型。

Implement with implement

使用 implement 实现契约

implement
turns the contract into an implementer that mirrors its shape and type-checks every handler;
.router
also enforces the contract at runtime.
ts
import { implement } from '@orpc/server'

const implementer = implement(contract).$context<{ db: DB }>()

const listPlanets = implementer.planet.list.handler(async ({ context }) => context.db.list())

const findPlanet = implementer.planet.find.handler(async ({ input, context, errors }) => {
  const planet = await context.db.find(input.id)
  if (!planet)
    throw errors.NOT_FOUND()
  return planet
})

export const router = implementer.router({
  planet: { list: listPlanets, find: findPlanet },
})
  • .$context
    declares the initial context the procedures require, as on
    os
    .
  • Apply middleware per procedure with
    .use(mw)
    before
    .handler
    . That runs after input validation (the contract already registered
    .input
    ); to wrap validation, apply it at router level:
    implementer.use(mw)
    for every procedure, or
    implementer.planet.use(mw).list
    for a subtree. Router-level plus procedure-level
    .use
    can run the same middleware twice; use the dedupe pattern from the
    orpc
    skill.
  • implementer.middleware(fn)
    creates middleware that infers the contract's typesafe errors. When not every procedure defines a code, guard with the
    in
    operator:
    if ('TOO_MANY_REQUESTS' in errors) throw errors.TOO_MANY_REQUESTS()
    . Any type-compatible middleware also works.
  • The result is a normal router: serve it with
    RPCHandler
    (
    orpc
    skill) or
    OpenAPIHandler
    (
    orpc-openapi
    skill), call it in-process with
    call
    or
    createRouterClient
    from
    @orpc/server
    .
implement
将契约转换为实现器,镜像契约的结构并对每个处理器进行类型检查;
.router
还会在运行时强制遵循契约。
ts
import { implement } from '@orpc/server'

const implementer = implement(contract).$context<{ db: DB }>()

const listPlanets = implementer.planet.list.handler(async ({ context }) => context.db.list())

const findPlanet = implementer.planet.find.handler(async ({ input, context, errors }) => {
  const planet = await context.db.find(input.id)
  if (!planet)
    throw errors.NOT_FOUND()
  return planet
})

export const router = implementer.router({
  planet: { list: listPlanets, find: findPlanet },
})
  • .$context
    声明过程所需的初始上下文,与
    os
    中的用法一致。
  • .handler
    之前使用
    .use(mw)
    可为单个过程应用中间件。该中间件会在输入验证之后运行(契约已注册
    .input
    );若要包裹验证逻辑,请在路由级别应用:
    implementer.use(mw)
    对所有过程生效,
    implementer.planet.use(mw).list
    对指定子树生效。路由级别和过程级别的
    .use
    可能会导致同一中间件运行两次;可使用
    orpc
    技能中的去重模式解决此问题。
  • implementer.middleware(fn)
    创建的中间件可推断契约的类型安全错误。若并非所有过程都定义了错误码,请使用
    in
    操作符进行判断:
    if ('TOO_MANY_REQUESTS' in errors) throw errors.TOO_MANY_REQUESTS()
    。任何类型兼容的中间件均适用。
  • 最终结果是一个普通路由:可使用
    RPCHandler
    orpc
    技能)或
    OpenAPIHandler
    orpc-openapi
    技能)部署,也可使用
    @orpc/server
    中的
    call
    createRouterClient
    在进程内调用。

Consume the contract from clients

通过客户端调用契约

RPCLink
needs only the contract type;
OpenAPILink
takes the contract as a runtime value to read each procedure's route. Get the client types exactly right:
ts
import type { RouterContractClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { OpenAPILink } from '@orpc/openapi/fetch'

// RPC protocol (server side is RPCHandler)
const rpcLink = new RPCLink({ origin: 'https://api.example.com', url: '/rpc' })
const client: RouterContractClient<typeof contract> = createORPCClient(rpcLink)

// OpenAPI protocol (OpenAPIHandler or any spec-compliant server)
const openapiLink = new OpenAPILink(contract, { origin: 'https://api.example.com', url: '/api' })
const apiClient: JsonifiedClient<RouterContractClient<typeof contract>> = createORPCClient(openapiLink)
  • Router-first equivalents use
    RouterClient<typeof router>
    from
    @orpc/server
    in the same positions.
  • JsonifiedClient
    is required over
    OpenAPILink
    because OpenAPI serialization is one-way (a
    Date
    returns as a string); dropping it via Smart Coercion, plus
    OpenAPILink
    options and CORS caveats, are in the
    orpc-openapi
    skill.
  • Per-call client context is the second type parameter,
    RouterContractClient<typeof contract, ClientContext>
    , then
    client.planet.find(input, { context: { token } })
    ; link options like
    headers
    accept functions of that context.
  • Export
    RouterContractClient<typeof contract>
    as a type from the server package so clients never import the contract module itself (still needed as a runtime value for
    OpenAPILink
    ; ship the minified JSON below).
  • In very large codebases, skip the root client: pin each procedure with
    .meta(meta.path([...]))
    and build per-procedure clients with
    createContractClientFactory
    from
    @orpc/contract
    (
    createContractJsonifiedClientFactory
    from
    @orpc/openapi
    when the link needs
    JsonifiedClient
    ); fetch
    contract/client-factory
    before adopting it.
RPCLink
仅需契约类型;
OpenAPILink
需要契约作为运行时值来读取每个过程的路由。请确保客户端类型完全正确:
ts
import type { RouterContractClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { OpenAPILink } from '@orpc/openapi/fetch'

// RPC 协议(服务端使用 RPCHandler)
const rpcLink = new RPCLink({ origin: 'https://api.example.com', url: '/rpc' })
const client: RouterContractClient<typeof contract> = createORPCClient(rpcLink)

// OpenAPI 协议(OpenAPIHandler 或任何符合规范的服务端)
const openapiLink = new OpenAPILink(contract, { origin: 'https://api.example.com', url: '/api' })
const apiClient: JsonifiedClient<RouterContractClient<typeof contract>> = createORPCClient(openapiLink)
  • 路由优先的等效用法是在相同位置使用
    @orpc/server
    中的
    RouterClient<typeof router>
  • OpenAPILink
    必须使用
    JsonifiedClient
    ,因为 OpenAPI 序列化是单向的(
    Date
    会返回为字符串);可通过智能强制转换省略该类型,相关
    OpenAPILink
    选项和 CORS 注意事项请参考
    orpc-openapi
    技能。
  • 单次调用的客户端上下文是第二个类型参数:
    RouterContractClient<typeof contract, ClientContext>
    ,调用方式为
    client.planet.find(input, { context: { token } })
    headers
    等链接选项可接受该上下文的函数。
  • 从服务端包导出
    RouterContractClient<typeof contract>
    作为类型,这样客户端无需导入契约模块本身(
    OpenAPILink
    仍需契约作为运行时值;可使用下文的压缩 JSON)。
  • 在超大型代码库中,可跳过根客户端:使用
    .meta(meta.path([...]))
    固定每个过程,并通过
    @orpc/contract
    中的
    createContractClientFactory
    创建单个过程的客户端(若链接需要
    JsonifiedClient
    ,则使用
    @orpc/openapi
    中的
    createContractJsonifiedClientFactory
    );使用前请查阅
    contract/client-factory
    文档。

Ship the contract

交付契约

When the contract is derived from a router, importing it on the client is heavy and may expose internals. Minify and export JSON instead:
ts
import fs from 'node:fs'
import { minifyRouterContract } from '@orpc/contract'
import { unlazyRouter } from '@orpc/server'

const minified = minifyRouterContract(await unlazyRouter(router))
fs.writeFileSync('./contract.json', JSON.stringify(minified))
minifyRouterContract
keeps only client-needed metadata. On the client, import the JSON and cast, since schemas do not survive serialization:
new OpenAPILink(contract as typeof router, ...)
.
To publish a typed SDK to npm, export a factory that pairs the contract with a link:
ts
import type { RouterContractClient } from '@orpc/contract'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'

export function createMyApi(apiKey: string): RouterContractClient<typeof contract> {
  const link = new RPCLink({
    origin: 'https://example.com',
    url: '/rpc',
    headers: { 'x-api-key': apiKey },
  })
  return createORPCClient(link)
}
Bundle with
tsdown --dts src/index.ts
, point
exports
at
dist
types plus import entries, list
@orpc/client
and
@orpc/contract
as dependencies, and publish. Consumers get a fully typed client that works with every oRPC client integration (TanStack Query included). Fetch
recipes/publish-client-to-npm
for the complete
package.json
.
当契约由路由派生而来时,客户端导入契约会增加体积,且可能暴露内部实现。建议将契约压缩为 JSON 后导出:
ts
import fs from 'node:fs'
import { minifyRouterContract } from '@orpc/contract'
import { unlazyRouter } from '@orpc/server'

const minified = minifyRouterContract(await unlazyRouter(router))
fs.writeFileSync('./contract.json', JSON.stringify(minified))
minifyRouterContract
仅保留客户端所需的元数据。在客户端,导入 JSON 并进行类型转换即可,因为模式无法通过序列化保留:
new OpenAPILink(contract as typeof router, ...)
若要将类型化 SDK 发布至 npm,请导出一个将契约与链接配对的工厂函数:
ts
import type { RouterContractClient } from '@orpc/contract'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'

export function createMyApi(apiKey: string): RouterContractClient<typeof contract> {
  const link = new RPCLink({
    origin: 'https://example.com',
    url: '/rpc',
    headers: { 'x-api-key': apiKey },
  })
  return createORPCClient(link)
}
使用
tsdown --dts src/index.ts
进行打包,将
exports
指向
dist
目录的类型文件和导入入口,将
@orpc/client
@orpc/contract
列为依赖项,然后发布。使用者将获得一个完全类型化的客户端,可与所有 oRPC 客户端集成(包括 TanStack Query)。完整的
package.json
配置请查阅
recipes/publish-client-to-npm
文档。

Generate the contract from an existing OpenAPI spec

从现有 OpenAPI 规范生成契约

Use Hey API's
orpc
plugin instead of hand-writing the contract. Install
@hey-api/openapi-ts@next
as a dev dependency (oRPC v2 output requires the
next
tag until the next stable Hey API release) and create
openapi-ts.config.ts
:
ts
import { defineConfig } from '@hey-api/openapi-ts'

export default defineConfig({
  input: 'https://example.com/openapi.json', // local file or URL
  output: 'src/contract',
  plugins: [{ name: 'orpc', compatibilityVersion: '2', validator: 'zod' }],
})
Then run
npx @hey-api/openapi-ts
. It writes
orpc.gen.ts
(one procedure contract per operation, routed via
.meta(openapi({...}))
with
inputStructure: 'detailed'
, plus a combined
contract
router) and
zod.gen.ts
. The generated files import
@orpc/contract
,
@orpc/openapi
, and
zod
, so install those too. From there, implement the contract on your own server, or point
OpenAPILink
at the existing spec-compliant server.
无需手动编写契约,可使用 Hey API 的
orpc
插件。安装
@hey-api/openapi-ts@next
作为开发依赖(oRPC v2 输出需要
next
标签,直至 Hey API 发布下一个稳定版本),并创建
openapi-ts.config.ts
ts
import { defineConfig } from '@hey-api/openapi-ts'

export default defineConfig({
  input: 'https://example.com/openapi.json', // 本地文件或 URL
  output: 'src/contract',
  plugins: [{ name: 'orpc', compatibilityVersion: '2', validator: 'zod' }],
})
然后运行
npx @hey-api/openapi-ts
。该命令会生成
orpc.gen.ts
(每个操作对应一个过程契约,通过
.meta(openapi({...}))
进行路由,
inputStructure
设为
'detailed'
,并包含一个组合后的
contract
路由)和
zod.gen.ts
。生成的文件会导入
@orpc/contract
@orpc/openapi
zod
,因此需要安装这些依赖。之后,你可以在自己的服务端实现该契约,或是将
OpenAPILink
指向现有的符合规范的服务端。

Full documentation

完整文档

If this skill and a fetched docs page disagree, trust the page: this skill is a summary and v2 is still moving. The docs are served at https://orpc.dev (the v1 docs stay at https://v1.orpc.dev):
Pages to fetch when you need details beyond this skill:
  • Contract:
    contract/procedure
    ,
    contract/router
    ,
    contract/implementation
    ,
    contract/generate-from-openapi
  • Clients:
    client/client-side
    ,
    client/server-side
    ,
    client/error-handling
    ,
    openapi/link
  • Workflows:
    recipes/publish-client-to-npm
    ,
    contract/client-factory
    ,
    recipes/monorepo-setup
若本技能与查阅的文档页面存在冲突,请以文档页面为准:本技能仅为摘要,且 v2 版本仍在迭代中。文档地址为 https://orpc.dev(v1 版本文档保留在 https://v1.orpc.dev):
若需要本技能未涵盖的细节,请查阅以下页面:
  • 契约相关:
    contract/procedure
    contract/router
    contract/implementation
    contract/generate-from-openapi
  • 客户端相关:
    client/client-side
    client/server-side
    client/error-handling
    openapi/link
  • 工作流相关:
    recipes/publish-client-to-npm
    contract/client-factory
    recipes/monorepo-setup