Step 04 of 8
Type-safe APIs end to end
Make the compiler check every call from React to your API: Hono's RPC client, shared schemas, typed errors, TanStack Query, and the alternatives (tRPC, OpenAPI).
The most common full-stack bug is a mismatch at the boundary: the API renames customerEmail to email, and the frontend keeps reading order.customerEmail, which is now undefined. Nothing fails until a user sees a blank field. With TypeScript on both sides and a shared contract, the compiler can catch this before you even run the code: change the API, and every affected frontend line turns red. This lesson builds that with Hono's typed RPC client, connects it to TanStack Query, handles errors and dates, and compares the alternatives.
The problem with hand-written fetch calls
type Order = { id: number; customerEmail: string; totalCents: number }
const res = await fetch(`/api/orders/${id}`)
const order = (await res.json()) as OrderThe as Order is a promise you make to the compiler, and nothing keeps it true. The path is a string (a typo is a 404 at runtime), the query parameters are untyped, the request body is untyped, and the response type is a copy that drifts from the API's real one. Every endpoint repeats this.
The fix is to make the API's own route definitions the source of the client's types.
Hono RPC: the client infers from the server
Hono can export the type of an app, including every route's path, method, input schema and response, and generate a typed client from it. Two things make it work on the server side: define routes by chaining on the Hono instance (so the type accumulates), and export the app's type:
// apps/api/src/orders/routes.ts
import { zValidator } from '@hono/zod-validator'
import { CreateOrder, OrderStatus } from '@shop/shared'
import { Hono } from 'hono'
import { z } from 'zod'
const ListQuery = z.object({
status: OrderStatus.optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
export const ordersRoutes = new Hono()
.get('/', zValidator('query', ListQuery), async (c) => {
const { status, limit } = c.req.valid('query')
const items = await listOrders({ status, limit })
return c.json({ items }, 200)
})
.get('/:id', zValidator('param', z.object({ id: z.coerce.number().int() })), async (c) => {
const order = await findOrder(c.req.valid('param').id)
if (!order) return c.json({ error: { code: 'not_found', message: 'Order not found' } }, 404)
return c.json(order, 200)
})
.post('/', zValidator('json', CreateOrder), async (c) => {
const order = await createOrder(c.req.valid('json'))
return c.json(order, 201)
})// apps/api/src/app.ts
import { Hono } from 'hono'
import { ordersRoutes } from './orders/routes.ts'
export const app = new Hono().basePath('/api').route('/orders', ordersRoutes)
export type AppType = typeof appMake the type importable by the frontend through the API package's exports:
{
"name": "@shop/api",
"type": "module",
"exports": { "./app-type": "./src/app.ts" }
}On the frontend, create a client from the type:
// apps/web/src/api.ts
import type { AppType } from '@shop/api/app-type'
import { hc } from 'hono/client'
export const api = hc<AppType>('/')import type is erased completely at build time: no server code reaches the browser bundle, only types reach the compiler. Now calls look like method calls, and everything is checked:
const res = await api.api.orders.$post({
json: { items: [{ sku: 'PEN-01', quantity: 2 }] }, // checked against CreateOrder
})
if (res.status === 201) {
const order = await res.json() // typed from the handler's return value
console.log(order.id, order.status)
}
await api.api.orders.$get({ query: { status: 'shipped', limit: '10' } })
await api.api.orders[':id'].$get({ param: { id: '42' } })Rename status in the API's order type, and order.status in the frontend becomes a compile error. Send status: 'shiped' and the compiler rejects it. That's the whole point: the contract is enforced by the type checker on every build. Query and path values are written as strings because that's what goes on the wire; the server's schemas coerce them.
What JSON does to your types
The client's response types describe JSON, not the objects the handler built. Hono's types follow what JSON.stringify does:
- A
Datebecomes astringin the response type, because JSON has no date type. Convert it back where you display it:new Date(order.createdAt). undefinedfields disappear;bigintcan't be serialised at all.- Class instances lose their methods.
Return plain data from handlers (numbers for money in cents, ISO strings for dates) and these differences stop being surprising.
Errors are part of the contract
Notice the GET /:id handler returns two shapes: the order with status 200, or an error with status 404, and the param validator adds a third, a 400 with Zod's error. Because every c.json call passes an explicit status, the client's types know each shape, and narrowing on res.status or res.ok picks the right one:
const res = await api.api.orders[':id'].$get({ param: { id } })
if (res.status === 404) {
const { error } = await res.json() // typed as the error shape
return { notFound: true, message: error.message }
}
if (!res.ok) throw new Error(`unexpected status ${res.status}`) // e.g. 400 from the param validator
const order = await res.json() // typed as the orderValidation failures (422 from your validate helper) and unexpected 500s come from middleware and onError, outside the route's own types. Handle them in one place with a small helper that turns any non-OK response into a thrown error with your API's error shape:
// apps/web/src/api.ts (continued)
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly details?: { path: string; message: string }[]
constructor(status: number, body: { error?: { code?: string; message?: string; details?: { path: string; message: string }[] } }) {
super(body.error?.message ?? `Request failed with status ${status}`)
this.status = status
this.code = body.error?.code ?? 'unknown'
this.details = body.error?.details
}
}
export async function unwrap<T extends { ok: boolean; status: number; json(): Promise<unknown> }>(
res: T,
): Promise<Awaited<ReturnType<Exclude<T, { ok: false }>['json']>>> {
if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))) as never)
return (await res.json()) as never
}const order = await unwrap(await api.api.orders.$post({ json: input })) // typed success body, or throws ApiErrorunwrap keeps only the response types whose ok is true. That relies on explicit status codes: a bare c.json(data) has an unknown status, so the client can't tell whether it's a success or an error shape. Always pass the status.
The form layer can then map ApiError.details onto fields, and a global handler can send the user to the login page on a 401.
With TanStack Query
The typed client slots straight into TanStack Query from the data fetching lesson. Wrap each endpoint once in a hook:
// apps/web/src/orders/queries.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { CreateOrder, OrderStatus } from '@shop/shared'
import { api, unwrap } from '../api.ts'
export function useOrders(status?: OrderStatus) {
return useQuery({
queryKey: ['orders', { status }],
queryFn: async () => unwrap(await api.api.orders.$get({ query: { status } })),
})
}
export function useCreateOrder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateOrder) => unwrap(await api.api.orders.$post({ json: input })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['orders'] }),
})
}function OrderList() {
const { data, isPending, error } = useOrders('pending')
if (isPending) return <p>Loading…</p>
if (error) return <p role="alert">{error.message}</p>
return <ul>{data.items.map((o) => <li key={o.id}>#{o.id}: {o.status}</li>)}</ul>
}data.items is fully typed from the API handler, through unwrap, through the hook, into the component, with no type written by hand on the frontend.
Alternatives
Hono RPC works because both sides are TypeScript in one repository. Other situations call for other tools:
| Approach | How it works | Best when |
|---|---|---|
| Hono RPC | Client inferred from the server app's type | Hono API and TypeScript frontend in one monorepo |
| tRPC | Procedures instead of REST routes; client inferred from the router type | TypeScript everywhere, and you don't need a public REST API |
| OpenAPI + code generation | The API publishes an OpenAPI document (generated from Zod schemas, for example); clients are generated from it | Public APIs, mobile apps, or clients in other languages |
| GraphQL | A typed schema; clients query exactly the fields they need, with generated types | Many clients with different data needs over a complex graph |
The OpenAPI approach is worth knowing even with Hono: libraries such as @hono/zod-openapi produce the document from the same schemas, so a mobile team or a partner can generate their own client.
Practice
In your shop monorepo:
- Export
AppTypefrom the API, and create thehcclient in the web app. - Implement
GET /api/orders(with optionalstatus),GET /api/orders/:id(404 with the error shape) andPOST /api/orders, backed by an in-memory store, with dates returned as ISO strings. - Add
unwrapandApiError, anduseOrders,useOrder(id)anduseCreateOrderhooks. - Build a page that lists orders, a form that creates one (showing
ApiError.detailsnext to the form), and a detail view that shows "Order not found" for a 404. - Rename a field in the API's order object and run
npm run typecheckat the root. Every frontend usage should fail to compile. Rename it back.
Show solution
// apps/api/src/orders/store.ts
import type { CreateOrder, OrderStatus } from '@shop/shared'
export type Order = CreateOrder & { id: number; status: OrderStatus; createdAt: string }
const orders = new Map<number, Order>()
let nextId = 1
export async function listOrders(opts: { status?: OrderStatus; limit: number }) {
return [...orders.values()].filter((o) => !opts.status || o.status === opts.status).slice(0, opts.limit)
}
export async function findOrder(id: number) {
return orders.get(id)
}
export async function createOrder(input: CreateOrder): Promise<Order> {
const order: Order = { ...input, id: nextId++, status: 'pending', createdAt: new Date().toISOString() }
orders.set(order.id, order)
return order
}// apps/web/src/orders/queries.ts (addition)
export function useOrder(id: number) {
return useQuery({
queryKey: ['orders', id],
queryFn: async () => {
const res = await api.api.orders[':id'].$get({ param: { id: String(id) } })
if (res.status === 404) return null // "not found" is data, not an error
return unwrap(res)
},
})
}// apps/web/src/orders/CreateOrderForm.tsx
import { useState } from 'react'
import { ApiError } from '../api.ts'
import { useCreateOrder } from './queries.ts'
export function CreateOrderForm() {
const create = useCreateOrder()
const [sku, setSku] = useState('PEN-01')
const [quantity, setQuantity] = useState(1)
const details = create.error instanceof ApiError ? create.error.details ?? [] : []
return (
<form
onSubmit={(e) => {
e.preventDefault()
create.mutate({ items: [{ sku, quantity }] })
}}
>
<input value={sku} onChange={(e) => setSku(e.target.value)} aria-label="SKU" />
<input type="number" value={quantity} onChange={(e) => setQuantity(Number(e.target.value))} aria-label="Quantity" />
<button disabled={create.isPending}>Create order</button>
{details.map((d) => (
<p key={d.path} role="alert">{d.path}: {d.message}</p>
))}
{create.isSuccess && <p>Created order #{create.data.id}</p>}
</form>
)
}Submitting a quantity of 0 shows items.0.quantity: Too small: expected number to be >=1 from the server's validation. Renaming createdAt to created in the API's Order type makes npm run typecheck fail in every component that reads order.createdAt, before anything runs. Returning null for a 404 in useOrder lets the detail view render "Order not found" as a normal state, while real failures still go to the query's error.
Key takeaways
- Hand-written
fetchplusas Typeis an unchecked claim; let the API's own route definitions produce the client's types. - With Hono, chain routes, export
typeof app, and create the client withhc<AppType>;import typekeeps server code out of the bundle. - Response types describe JSON: dates become strings, so return plain data from handlers.
- Give errors explicit status codes, narrow on
res.status, and convert unexpected failures into a typedApiErrorin one helper. - Wrap endpoints in TanStack Query hooks; choose tRPC, OpenAPI or GraphQL when clients aren't TypeScript in the same repository, and keep API changes backwards compatible.
Check yourself
Quiz
0/4 correct · 4 left
1.Why must Hono routes be defined by chaining (.get().post()...) for the RPC client to be fully typed?
2.The API handler returns { createdAt: new Date() }. What type does the Hono client give createdAt?
3.Why does the frontend import AppType with import type?
4.A partner needs to call your API from Kotlin. Which approach fits best?
Finished reading?
Mark it complete to fill in your roadmap.