# 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).

Full-Stack Developer roadmap · Connect them · 17 min read · https://levelcode.org/roadmaps/full-stack/type-safe-apis

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

```ts
type Order = { id: number; customerEmail: string; totalCents: number }

const res = await fetch(`/api/orders/${id}`)
const order = (await res.json()) as Order
```

The `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:

```ts
// 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)
  })
```

```ts
// 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 app
```

Make the type importable by the frontend through the API package's `exports`:

```json
{
  "name": "@shop/api",
  "type": "module",
  "exports": { "./app-type": "./src/app.ts" }
}
```

On the frontend, create a client from the type:

```ts
// 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:

```ts
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 `Date` becomes a `string` in the response type, because JSON has no date type. Convert it back where you display it: `new Date(order.createdAt)`.
- `undefined` fields disappear; `bigint` can'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:

```ts
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 order
```

Validation 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:

```ts
// 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
}
```

```ts
const order = await unwrap(await api.api.orders.$post({ json: input }))   // typed success body, or throws ApiError
```

`unwrap` 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](https://levelcode.org/roadmaps/frontend/data-fetching). Wrap each endpoint once in a hook:

```ts
// 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'] }),
  })
}
```

```tsx
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.

> [!NOTE]
> Type safety stops at the network. The client's types describe what the *current* server code returns, but a browser may be running last week's JavaScript against today's API during a deploy. Keep API changes backwards compatible (add fields; don't rename or remove them until old clients are gone), and keep validating input on the server.

## Practice

In your `shop` monorepo:

1. Export `AppType` from the API, and create the `hc` client in the web app.
2. Implement `GET /api/orders` (with optional `status`), `GET /api/orders/:id` (404 with the error shape) and `POST /api/orders`, backed by an in-memory store, with dates returned as ISO strings.
3. Add `unwrap` and `ApiError`, and `useOrders`, `useOrder(id)` and `useCreateOrder` hooks.
4. Build a page that lists orders, a form that creates one (showing `ApiError.details` next to the form), and a detail view that shows "Order not found" for a 404.
5. Rename a field in the API's order object and run `npm run typecheck` at the root. Every frontend usage should fail to compile. Rename it back.

**Solution**

```ts
// 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
}
```

```ts
// 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)
    },
  })
}
```

```tsx
// 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 `fetch` plus `as Type` is 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 with `hc<AppType>`; `import type` keeps 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 typed `ApiError` in 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.

## Quiz

1. Why must Hono routes be defined by chaining (.get().post()...) for the RPC client to be fully typed?

   A. Separate statements are slower
   B. Each chained call returns a Hono instance whose type includes all routes so far; separate statements don't change the variable's type
   C. Hono throws an error otherwise
   D. Chaining enables validation

   **Answer: B.** TypeScript tracks the route types through the return values. A variable declared once and mutated by separate calls keeps its original, route-less type.

2. The API handler returns { createdAt: new Date() }. What type does the Hono client give createdAt?

   A. Date
   B. number
   C. string, because JSON serialises dates as strings
   D. unknown

   **Answer: C.** The response travels as JSON, which has no date type. The client's types follow JSON serialisation, so convert the string back to a Date where needed.

3. Why does the frontend import AppType with import type?

   A. The import is erased at build time, so no API server code ends up in the browser bundle
   B. It makes the client faster
   C. Hono requires it
   D. It allows runtime type checks

   **Answer: A.** Only the compiler sees the type. The bundle contains just the tiny hc client, never the API's handlers or database code.

4. A partner needs to call your API from Kotlin. Which approach fits best?

   A. Hono RPC
   B. tRPC
   C. Sharing the TypeScript types by email
   D. Publish an OpenAPI document generated from your schemas and let them generate a client

   **Answer: D.** Hono RPC and tRPC rely on TypeScript type inference in one codebase. OpenAPI is language-neutral, and generators exist for most languages.
