Skip to content

类型共享

要点

  • shared contract 只放跨端稳定约定,不放业务实现
  • 接口返回值建议从一开始就分成两层语义
  • 这里做一个很实用的拆分
  • 后端 contract 定好了,接下来就该让前端真正共享这份类型收益

内容

1. 概述

接下来,我们要约定在 web 端和 api 端之间共享请求类型,并使用 Hono RPC 进行通信。

目前 API 侧现在只有一个 GET /health,返回的还是裸 JSON。前端侧也没有真正成型的请求层。继续往下写业务,很快就会碰到三个问题:

  • 请求参数靠口头约定
  • 成功和失败返回格式各写各的
  • 前端拿到接口后,类型还是得自己再补一遍

这类问题一开始不明显,接口一多就会变成维护负担。你会看到同一个错误,在 web、admin、api 三处分别写一遍;同一个字段一旦改名,类型漂移会沿着整条链路扩散。

因此,我们的目标很明确:

  • 将 contract 放进共享包,前后端共用同一份类型和 schema
  • 统一 success / failure 返回结构
  • 前端直接基于 Hono RPC 调用,拿到真实类型推导
  • 用首页上的一次 ping 调用验证整条链路是否通顺

2. 定义 contract 边界

shared contract 只放跨端稳定约定,不放业务实现。放进 packages/contracts 的内容控制在这几类:

  • 业务异常码 BizCode
  • 统一响应元信息 ApiMeta
  • 成功结构 ApiSuccess<T> 和 失败结构 ApiFailure<E>
  • 总响应类型 ApiResponse<T, E>
  • ping 这条最小链路的输入输出 schema 与类型

这里最关键的点有两个。

第一,输入输出都要有 schema

只写 TypeScript type 不够,因为 type 只在编译期存在,真正到接口入口时,传进来的 JSON 还是运行时数据。这里直接用 zod 定义 PingRequestSchemaPingResponseSchema,这样 API 可以校验入参,前端也能复用同一份 contract。

第二,响应 envelope 也要共享

如果每个 route 自己拼 { ok, data, error, meta },后面一多就会失控。所以直接在共享包里提供 buildSuccessbuildFailure 这类纯数据 helper,把结构先固定下来。

可以先把 contract 包写成这样:

typescript
// packages/contracts/src/index.ts
import { z } from 'zod'

export const BizCode = {

  COMMON_INVALID_REQUEST: 'COMMON.INVALID_REQUEST',

  COMMON_NOT_FOUND: 'COMMON.NOT_FOUND',

  AUTH_UNAUTHORIZED: 'AUTH.UNAUTHORIZED',

  AUTH_FORBIDDEN: 'AUTH.FORBIDDEN',

  BIZ_CONFLICT: 'BIZ.CONFLICT',

  BIZ_RULE_VIOLATION: 'BIZ.RULE_VIOLATION',

  SYSTEM_INTERNAL_ERROR: 'SYSTEM.INTERNAL_ERROR',

  SYSTEM_UPSTREAM_TIMEOUT: 'SYSTEM.UPSTREAM_TIMEOUT',

} as const

export type BizCode = (typeof BizCode)[keyof typeof BizCode]

export interface ApiMeta {

  requestId: string

  timestamp: string

}

export interface ApiSuccess<T> {

  ok: true

  data: T

  meta: ApiMeta

}

export interface ApiError<E = unknown> {

  code: BizCode

  message: string

  details?: E

}

export interface ApiFailure<E = unknown> {

  ok: false

  error: ApiError<E>

  meta: ApiMeta

}

export type ApiResponse<T, E = unknown> = ApiSuccess<T> | ApiFailure<E>

export const PingRequestSchema = z.object({

  name: z.string().trim().min(1),

})

export const PingResponseSchema = z.object({

  service: z.literal('api'),

  message: z.string(),

})

export type PingRequest = z.infer<typeof PingRequestSchema>

export type PingResponse = z.infer<typeof PingResponseSchema>

export function buildSuccess<T>(data: T, meta: ApiMeta): ApiSuccess<T> {

  return { ok: true, data, meta }

}

export function buildFailure<E = unknown>(

  error: ApiError<E>,

  meta: ApiMeta,

): ApiFailure<E> {

  return { ok: false, error, meta }

}

到这里,前后端至少已经在「说同一种语言 」。

3. 统一响应格式

接口返回值建议从一开始就分成两层语义:

  • HTTP status,表示传输层结果
  • error.code,表示业务语义

成功结构统一为:

json
// index.json
{

  "ok": true,

  "data": {

    "service": "api",

    "message": "pong, web"

  },

  "meta": {

    "requestId": "d7c4f4ef-67c3-4f48-90b2-0cb6c6f7ea4f",

    "timestamp": "2026-04-28T08:00:00.000Z"

  }

}

失败结构统一成:

json
// index.json
{

  "ok": false,

  "error": {

    "code": "COMMON.INVALID_REQUEST",

    "message": "Invalid request payload",

    "details": {

      "fieldErrors": {

        "name": ["String must contain at least 1 character(s)"]

      }

    }

  },

  "meta": {

    "requestId": "8c9b0b52-ef80-44d8-b2e1-d40c6b90a40d",

    "timestamp": "2026-04-28T08:00:01.000Z"

  }

}

这里的 meta 不只是为了好看。

requestId 让日志串联有锚点,timestamp 让排查时能快速定位请求时间。后面真接日志平台、链路追踪或者 Sentry,这两个字段都能直接复用。

配套的业务异常码可以先收一组通用常量:

  • COMMON.INVALID_REQUEST
  • COMMON.NOT_FOUND
  • AUTH.UNAUTHORIZED
  • AUTH.FORBIDDEN
  • BIZ.CONFLICT
  • BIZ.RULE_VIOLATION
  • SYSTEM.INTERNAL_ERROR
  • SYSTEM.UPSTREAM_TIMEOUT

这组常量先别追求全面,关键是命名风格和职责边界要稳定。后面新增业务 route 时,直接在这个集合里继续扩展。

4. RPC 与异常处理

这里做一个很实用的拆分:

  • apps/api/src/app.ts 负责定义路由、错误处理、导出 AppType
  • apps/api/src/index.ts 只负责 export default app

这样做的目的可以让前端 type-only 导入 AppType,拿到 Hono route 的真实类型推导。

先看 app.ts 的最小形态:

typescript
// apps/api/src/app.ts
import {

  BizCode,

  PingRequestSchema,

  buildFailure,

  buildSuccess,

  type ApiMeta,

} from '@repo/contracts'

import { Hono } from 'hono'

import { HTTPException } from 'hono/http-exception'

import { validator } from 'hono/validator'

type AppErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 504

class AppError extends Error {

  constructor(

    readonly code: BizCode,

    message: string,

    readonly status: AppErrorStatus,

    readonly details?: unknown,

  ) {

    super(message)

  }

}

const app = new Hono()

function createMeta(): ApiMeta {

  return {

    requestId: crypto.randomUUID(),

    timestamp: new Date().toISOString(),

  }

}

app.onError((error, c) => {

  const meta = createMeta()

  if (error instanceof AppError) {

    const errorMsg = { code: error.code, message: error.message, details: error.details }

    const res = buildFailure(errorMsg, meta);

    return c.json(res, error.status);

  }

  if (error instanceof HTTPException) {

    const errorMsg = { code: BizCode.COMMON_INVALID_REQUEST, message: error.message }

    const res = buildFailure(errorMsg, meta);

    return c.json(res, error.status);

  }

  console.error(error)

  const errorMsg = { code: BizCode.SYSTEM_INTERNAL_ERROR, message: 'Internal server error' }

  const res = buildFailure(errorMsg, meta);

  return c.json(res, 500);

})

app.notFound((c) => {

  const errorMsg = { code: BizCode.COMMON_NOT_FOUND, message: 'Not found' }

  const res = buildFailure(errorMsg, createMeta());

  return c.json(res, 404);

})

const routes = app

  .get('/health', (c) => {

    const res = buildSuccess({ service: 'api' }, createMeta());

    return c.json(res);

  })

  .post('/rpc/system/ping', validator('json', (value, c) => {

      const parsed = PingRequestSchema.safeParse(value)

      if (!parsed.success) {

        const errorMsg = {

          code: BizCode.COMMON_INVALID_REQUEST,

          message: 'Invalid request payload',

          details: parsed.error.flatten(),

        }

        return c.json(buildFailure(errorMsg, createMeta()), 400);

      }

      return parsed.data

    }),

    (c) => {

      const payload = c.req.valid('json')

      const successMsg = { service: 'api', message: `pong, ${payload.name}` } 

      const res = buildSuccess(successMsg, createMeta());

    return c.json(res);

  });

export type AppType = typeof routes;

export default app;

入口文件就保持极简:

typescript
// apps/api/src/index.ts
import app from './app'

export default app

这一步的价值在于,route 结构、入参校验、返回值 envelope、异常码映射,已经统一到一个地方。

5. 前端直接走 typed RPC

后端 contract 定好了,接下来就该让前端真正共享这份类型收益。

这里不额外包一层请求 SDK,先在 web 首页直接走一次最小调用,目的就是验证链路,而不是过早抽象。

要做的接入只有三步。

第一步,补 workspace 依赖。

apps/web/package.json 增加:

  • @repo/api
  • @repo/contracts
  • hono

apps/api/package.json 也要补 workspace 包名和 exports,让 AppType 能被前端引用。

json
// apps/api/package.json
{

  "name": "@repo/api",

  "type": "module",

  "exports": {

    ".": "./src/app.ts"

  },

  "scripts": {

    "dev": "wrangler dev",

    "deploy": "wrangler deploy --minify",

    "cf-typegen": "wrangler types --env-interface CloudflareBindings",

    "check-types": "tsc --noEmit"

  },

  "dependencies": {

    "@repo/contracts": "workspace:*",

    "hono": "^4.12.14"

  },

  "devDependencies": {

    "typescript": "catalog:",

    "wrangler": "^4.4.0"

  }

}

apps/web/next.config.js 也要把共享包加进 transpilePackages

javascript
// apps/web/next.config.js
/** @type {import('next').NextConfig} */

const nextConfig = {

  transpilePackages: ['@repo/ui', '@repo/contracts', '@repo/api'],

}

export default nextConfig

第二步,首页直接连 RPC。

这里用 hc<AppType>() 建 client,前端就能拿到 route 对应的参数和返回值推导。

tsx
// apps/web/app/page.tsx
import type { AppType } from '@repo/api'

import {

  BizCode,

  type ApiResponse,

  type PingRequest,

  type PingResponse,

} from '@repo/contracts'

import { hc, type InferResponseType } from 'hono/client'

const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://127.0.0.1:8787'

const rpcPayload: PingRequest = { name: 'web' }

type PingRpcResponse = InferResponseType<

  ReturnType<typeof hc<AppType>>['rpc']['system']['ping']['$post']

>

async function getPingResponse(): Promise<PingRpcResponse> {

  const client = hc<AppType>(apiBaseUrl)

  try {

    const response = await client.rpc.system.ping.$post({

      json: rpcPayload,

    })

    return await response.json()

  } catch (error) {

    return {

      ok: false,

      error: {

        code: BizCode.SYSTEM_UPSTREAM_TIMEOUT,

        message: error instanceof Error ? error.message : 'API request failed',

      },

      meta: {

        requestId: 'unavailable',

        timestamp: new Date().toISOString(),

      },

    } satisfies ApiResponse<PingResponse>

  }

}

第三步,把调用结果直接展示在首页。

这个展示区块不需要做花活,能看清请求体、返回值和错误码就够了。

tsx
// apps/web/app/page.tsx
const requestBody = JSON.stringify(rpcPayload, null, 2)

const responseBody = JSON.stringify(pingResult, null, 2)

<section className="py-10">

  <Card className="overflow-hidden border border-border bg-background shadow-soft">

    <CardContent className="space-y-5 p-6">

      <div className="space-y-2">

        <p className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">

          RPC validation

        </p>

        <h2 className="text-2xl font-semibold tracking-tight text-foreground">

          Shared request and response contract

        </h2>

      </div>

      <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">

        <span className="rounded-full border border-border px-3 py-1">

          POST /rpc/system/ping

        </span>

        <span className="rounded-full border border-border px-3 py-1">

          {pingResult.ok ? 'ok=true' : `code=${pingResult.error.code}`}

        </span>

      </div>

      <div className="grid gap-4 lg:grid-cols-2">

        <div className="rounded-2xl border border-border bg-muted/40 p-4">

          <p className="text-sm font-medium text-foreground">Request</p>

          <pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-muted-foreground">

            {requestBody}

          </pre>

        </div>

        <div className="rounded-2xl border border-border bg-muted/40 p-4">

          <p className="text-sm font-medium text-foreground">Response</p>

          <pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-muted-foreground">

            {responseBody}

          </pre>

        </div>

      </div>

    </CardContent>

  </Card>

</section>

这样,我们就可以直接在首页上验证共享请求类型和响应类型是否跑通。

6. 验证

做到这里,其实已经验证了四件关键的事。

共享请求类型已经跑通。

PingRequestSchemaPingRequest 来自同一个共享包,API 用它校验,前端用它约束入参。字段一旦变化,前后端会一起感知。

共享响应类型已经跑通。

PingResponseApiResponse<T> 让成功结构、失败结构、元信息结构都固定下来。后面新增接口,不需要每次重新发明一套返回格式。

异常码已经有了统一出口。

无论是 notFound、参数校验失败,还是运行时异常,最后都会汇总到统一的 failure envelope。前端读取错误信息时,也不再猜字段名。

Hono RPC 的类型推导已经接上。

前端通过 hc<AppType>() 直接消费 API route 类型,这意味着 route 路径、请求体、返回值三者已经串到一起。

这个阶段先别急着抽出通用 rpcClientfetcherservice layer。当前目标只是验证方向,最小链路能跑通,后面的抽象才有依据。

跑通之后,后面的用户、鉴权、任务、消息这些业务 route,就都有统一入口可接了:先在 packages/contracts 补 schema 和类型,再在 apps/api/src/app.ts 补 route,最后让前端通过 hc<AppType>() 消费。

基于 MIT 协议开源