Hono RPC + Zod: type-safe từ server tới client không cần codegen

Câu trả lời ngắn: RPC của Hono cho type chạy thẳng từ route ở server sang chỗ gọi ở client mà không cần codegen — bạn export type AppType = typeof route ở server, rồi hc<AppType>() ở client. Điều kiện sống còn ít ai nói trước: các route phải được method chaining (.get().post()...) thì TypeScript mới suy được toàn bộ cấu trúc. Đây là phần tôi đào sâu sau khi đã giới thiệu RPC ở bài trải nghiệm Hono.

TL;DR (Executive Summary)

  • Bài toán: Ở mọi app có frontend gọi API riêng, type đứt ngay tại biên network. Đổi shape một endpoint là phải đi sửa tay chỗ gọi, và compiler im lặng cho tới khi chạy mới lỗi.
  • Giải pháp: Định nghĩa route kèm zValidator (Zod), export type cái route, rồi dùng hc() ở client. Type của input (validator) và output (c.json()) đều được suy.
  • Kết quả: Đổi schema ở server → client đỏ ngay lúc compile. Nhưng phải nắm 3 quy tắc: chaining, app.route() khi tách file, và strict: true ở cả hai phía monorepo.

RPC ở đây nghĩa là gì (và khác tRPC ra sao)?

RPC trong Hono không phải một giao thức mới. Route của bạn vẫn là REST bình thường — vẫn gọi được bằng curl. "RPC" chỉ là một lớp type mỏng: client hc() đọc kiểu AppType của server và phơi ra các method gọi đã được gõ type sẵn, phủ lên fetch.

Khác biệt với tRPC: tRPC dựng một giao thức riêng phía trên HTTP. Hono giữ nguyên HTTP/fetch chuẩn. Nếu bạn muốn endpoint vẫn là API thường mà vẫn type-safe trong TypeScript, mô hình của Hono hợp hơn — và bạn không khóa client vào một runtime tRPC.

Bước 1: validate bằng Zod, lấy type miễn phí

zValidator nhận một "target" (json, query, param, form, header, cookie) và một schema Zod. Sau khi qua nó, c.req.valid(target) trả về dữ liệu đã xác thực kèm type:

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()
  .post(
    '/notes',
    zValidator('json', z.object({ title: z.string(), body: z.string() })),
    (c) => {
      const data = c.req.valid('json')   // { title: string; body: string }
      return c.json({ id: 1, ...data }, 201)
    }
  )
  .get(
    '/notes',
    zValidator('query', z.object({ page: z.coerce.number().default(1) })),
    (c) => {
      const { page } = c.req.valid('query')   // page: number (đã coerce)
      return c.json({ page, items: [] }, 200)
    }
  )

Hai thứ cần để ý: schema vừa chặn request rác ở runtime (trả 400 nếu sai), vừa sinh type ở compile-time. Một nguồn sự thật, hai tác dụng. Và để ý tôi luôn ghi rõ status code trong c.json(x, 201) — điều này cần cho việc suy type response ở client (xem dưới).

Bước 2: gotcha lớn nhất — phải method chaining

Đây là chỗ làm tốn thời gian nhất. RPC suy type từ kiểu của biến app. Nếu bạn viết mỗi route thành một lệnh rời rạc, kiểu đó không tích lũy được:

// ❌ SAI — client sẽ không thấy route nào
const app = new Hono()
app.get('/notes', handlerA)
app.post('/notes', handlerB)
export type AppType = typeof app   // type rỗng về mặt RPC
// ✅ ĐÚNG — nối liền thành một chuỗi rồi export
const app = new Hono()
  .get('/notes', handlerA)
  .post('/notes', handlerB)
export type AppType = typeof app   // suy đủ cả 2 route

Khi project lớn lên và bạn muốn tách file, đừng quay lại kiểu rời rạc. Tách bằng app.route(), mỗi file vẫn là một chuỗi chaining:

// notes.ts
const notes = new Hono()
  .get('/', (c) => c.json([{ id: 1 }]))
  .post('/', zValidator('json', schema), (c) => c.json({ ok: true }, 201))
export default notes

// index.ts
import notes from './notes'
const app = new Hono().route('/notes', notes)
export type AppType = typeof app
export default app

Nếu client báo "không có type" dù đã export — 9/10 lần là vì chỗ nào đó tuột khỏi chaining.

Bước 3: hc() client — gọi như RPC, vẫn là fetch

Phía client, truyền AppType vào hc. Đường dẫn map theo cấu trúc route, method là $get/$post:

import { hc } from 'hono/client'
import type { AppType } from './server'

const client = hc<AppType>('https://api.example.com/')

const res = await client.notes.$post({
  json: { title: 'Học Hono', body: 'ghi chú trung tính' },
})

if (res.ok) {
  const note = await res.json()   // type suy từ c.json() ở server
}

// param và query luôn truyền dạng string, kể cả khi schema là number
const list = await client.notes.$get({ query: { page: '2' } })

Lưu ý hai điều: tham số param/query luôn là string (đó là bản chất URL — Zod coerce lo phần đổi kiểu ở server), và response chỉ được suy type nếu handler trả bằng c.json() có status code. Response trả từ middleware thì client không thấy type — nên với RPC, tránh c.notFound(), hãy c.json({ error: '...' }, 404).

Bước 4: tái dùng type ở client

Không cần khai báo lại interface ở frontend. Rút type trực tiếp từ client bằng InferRequestType/InferResponseType — rất hợp khi ráp với form hoặc react-query:

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

type NewNote = InferRequestType<typeof client.notes.$post>['json']
type NoteRes = InferResponseType<typeof client.notes.$post>

Đổi schema Zod ở server → cả NewNote lẫn NoteRes đổi theo, và mọi chỗ dùng sai sẽ đỏ lúc compile. Đây chính là thứ xóa bỏ cả một lớp bug "frontend và backend lệch nhau".

Monorepo: hai quy tắc để không hỏng inference

Khi server và client nằm chung một monorepo, có hai điều bắt buộc:

  1. "strict": true trong tsconfig.json của cả hai phía. Thiếu nó, suy type RPC sẽ hỏng âm thầm.
  2. Client chỉ cần import type { AppType } từ package server — không import code thật, không bundle server vào client.
// tsconfig.json (cả client lẫn server)
{
  "compilerOptions": {
    "strict": true
  }
}

Một mẹo từ docs mà tôi xác nhận đúng khi route nhiều: biên dịch type ra .d.ts trước rồi mới để client tiêu thụ, thay vì để TypeScript suy lại toàn bộ chuỗi route trong IDE mỗi lần. Với hàng chục–trăm route, đây là khác biệt giữa editor mượt và editor ì.

Câu hỏi thường gặp

Có bắt buộc dùng Zod không, hay validator khác cũng được?

Không bắt buộc Zod, nhưng nó là lựa chọn phổ biến nhất vì hệ @hono/zod-validator gọn và cho cả runtime-validation lẫn type-inference từ một schema. Hono hỗ trợ các validator khác (Valibot, TypeBox...) qua middleware tương ứng. Điểm cốt lõi không nằm ở thư viện, mà ở việc bạn để một schema vừa chặn dữ liệu rác vừa sinh type — đừng tách hai việc đó ra hai nguồn sự thật.

RPC này có chạy được khi client và server khác repo không?

Được, miễn là client lấy được AppType lúc build. Cách thường dùng là publish type của server thành một package (hoặc một file .d.ts) để client import type. Nó kém liền mạch hơn monorepo một chút vì bạn phải version cái type đó, nhưng bản chất vẫn là chia sẻ type qua TypeScript, không phải codegen. Nếu hai bên đổi nhịp độ độc lập, cân nhắc thêm OpenAPI; còn trong một monorepo thì RPC thẳng là gọn nhất.


Bài này đào sâu phần RPC đã teaser ở trải nghiệm thực chiến Hono. Nếu bạn đang phân vân có nên đưa Hono vào hệ thống thật hay không, đọc Có nên dùng Hono cho production?. Cần trao đổi về một lớp API type-safe hay tái cấu trúc hạ tầng, xem thêm câu chuyện dự án hoặc kết nối.