Hono RPC - Type-Safe Client Overview
Hono RPC enables sharing API specifications between server and client through TypeScript's type system. Export your server's type, and the client automatically knows all routes, request shapes, and response types - no code generation required.
Key Features:
Zero-codegen type-safe client Automatic TypeScript inference Works with Zod validators Status code-aware response types Supports path params, query, headers When to Use This Skill
Use Hono RPC when:
Building full-stack TypeScript applications Need type-safe API consumption without OpenAPI/codegen Want compile-time validation of API calls Sharing types between client and server in monorepos Basic Setup Server Side // server/index.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod'
const app = new Hono()
// Define routes with validation const route = app .get('/users', async (c) => { const users = [{ id: '1', name: 'Alice' }] return c.json({ users }) }) .post( '/users', zValidator('json', z.object({ name: z.string(), email: z.string().email() })), async (c) => { const data = c.req.valid('json') return c.json({ id: '1', ...data }, 201) } ) .get('/users/:id', async (c) => { const id = c.req.param('id') return c.json({ id, name: 'Alice' }) })
// Export type for client export type AppType = typeof route
export default app
Client Side // client/api.ts import { hc } from 'hono/client' import type { AppType } from '../server'
// Create typed client
const client = hc
// All methods are type-safe! async function examples() { // GET /users const usersRes = await client.users.$get() const { users } = await usersRes.json() // users: { id: string; name: string }[]
// POST /users - body is typed const createRes = await client.users.$post({ json: { name: 'Bob', email: 'bob@example.com' } }) const created = await createRes.json() // created: { id: string; name: string; email: string }
// GET /users/:id - params are typed const userRes = await client.users[':id'].$get({ param: { id: '123' } }) const user = await userRes.json() // user: { id: string; name: string } }
Route Chaining for Type Export
Important: Chain routes for proper type inference:
// CORRECT: Chain all routes const route = app .get('/a', handlerA) .post('/b', handlerB) .get('/c', handlerC)
export type AppType = typeof route
// WRONG: Separate statements lose type info app.get('/a', handlerA) app.post('/b', handlerB) // Types lost!
export type AppType = typeof app // Missing routes!
Request Patterns Path Parameters // Server const route = app.get('/posts/:postId/comments/:commentId', async (c) => { const { postId, commentId } = c.req.param() return c.json({ postId, commentId }) })
// Client const res = await client.posts[':postId'].comments[':commentId'].$get({ param: { postId: '1', commentId: '42' } })
Query Parameters // Server const route = app.get( '/search', zValidator('query', z.object({ q: z.string(), page: z.coerce.number().optional(), limit: z.coerce.number().optional() })), async (c) => { const { q, page, limit } = c.req.valid('query') return c.json({ query: q, page, limit }) } )
// Client const res = await client.search.$get({ query: { q: 'typescript', page: 1, limit: 20 } })
JSON Body // Server const route = app.post( '/posts', zValidator('json', z.object({ title: z.string(), content: z.string(), tags: z.array(z.string()).optional() })), async (c) => { const data = c.req.valid('json') return c.json({ id: '1', ...data }, 201) } )
// Client const res = await client.posts.$post({ json: { title: 'Hello World', content: 'My first post', tags: ['typescript', 'hono'] } })
Form Data // Server const route = app.post( '/upload', zValidator('form', z.object({ file: z.instanceof(File), description: z.string().optional() })), async (c) => { const { file, description } = c.req.valid('form') return c.json({ filename: file.name }) } )
// Client const formData = new FormData() formData.append('file', file) formData.append('description', 'My file')
const res = await client.upload.$post({ form: formData })
Headers // Server const route = app.get( '/protected', zValidator('header', z.object({ authorization: z.string() })), async (c) => { return c.json({ authenticated: true }) } )
// Client const res = await client.protected.$get({ header: { authorization: 'Bearer token123' } })
Response Type Inference Status Code-Aware Types // Server const route = app.get('/user', async (c) => { const user = await getUser()
if (!user) { return c.json({ error: 'Not found' }, 404) }
return c.json({ id: user.id, name: user.name }, 200) })
// Client - use InferResponseType import { InferResponseType } from 'hono/client'
type SuccessResponse = InferResponseType
type ErrorResponse = InferResponseType
// Handle different status codes const res = await client.user.$get()
if (res.status === 200) { const data = await res.json() // data: { id: string; name: string } } else if (res.status === 404) { const error = await res.json() // error: { error: string } }
Request Type Inference import { InferRequestType } from 'hono/client'
type CreateUserRequest = InferRequestType
// Use for form validation, state management, etc.
const [formData, setFormData] = useState
Multi-File Route Organization Organize Routes // server/routes/users.ts import { Hono } from 'hono'
export const users = new Hono() .get('/', async (c) => c.json({ users: [] })) .post('/', async (c) => c.json({ created: true }, 201)) .get('/:id', async (c) => c.json({ id: c.req.param('id') }))
// server/routes/posts.ts export const posts = new Hono() .get('/', async (c) => c.json({ posts: [] })) .post('/', async (c) => c.json({ created: true }, 201))
// server/index.ts import { Hono } from 'hono' import { users } from './routes/users' import { posts } from './routes/posts'
const app = new Hono()
const route = app .route('/users', users) .route('/posts', posts)
export type AppType = typeof route export default app
Client Usage import { hc } from 'hono/client' import type { AppType } from '../server'
const client = hc
// Routes are nested await client.users.$get() // GET /users await client.users[':id'].$get() // GET /users/:id await client.posts.$get() // GET /posts
Error Handling Handle Fetch Errors async function fetchUser(id: string) { try { const res = await client.users[':id'].$get({ param: { id } })
if (!res.ok) {
const error = await res.json()
throw new Error(error.message || 'Failed to fetch user')
}
return await res.json()
} catch (error) { if (error instanceof TypeError) { // Network error throw new Error('Network error') } throw error } }
Type-Safe Error Responses // Server const route = app.get('/resource', async (c) => { try { const data = await fetchData() return c.json({ success: true, data }) } catch (e) { return c.json({ success: false, error: 'Failed' }, 500) } })
// Client
type ApiResponse
const res = await client.resource.$get()
const result: ApiResponse
if (result.success) { console.log(result.data) // Typed! } else { console.error(result.error) }
Configuration Options
Custom Fetch
const client = hc
Default Headers
const client = hc
Dynamic Headers
const getClient = (token: string) =>
hcBearer ${token}
})
})
// Or with a function that returns headers
const client = hcBearer ${token} } : {}
}
})
Best Practices 1. Enable Strict Mode // tsconfig.json { "compilerOptions": { "strict": true // Required for proper type inference! } }
- Use Explicit Status Codes // CORRECT: Explicit status enables type discrimination return c.json({ data }, 200) return c.json({ error: 'Not found' }, 404)
// AVOID: c.notFound() doesn't work well with RPC return c.notFound() // Response type is not properly inferred
- Split Large Apps // For large apps, split routes to reduce IDE overhead const v1 = new Hono() .route('/users', usersRoute) .route('/posts', postsRoute)
const v2 = new Hono() .route('/users', usersV2Route)
// Export separate types export type V1Type = typeof v1 export type V2Type = typeof v2
- Consistent Response Shapes
// Define standard response wrapper
type ApiSuccess
= { ok: true; data: T } type ApiError = { ok: false; error: string; code?: string } type ApiResponse = ApiSuccess | ApiError
// Use consistently const route = app.get('/users/:id', async (c) => { const user = await findUser(c.req.param('id'))
if (!user) { return c.json({ ok: false, error: 'User not found' } as ApiError, 404) }
return c.json({ ok: true, data: user } as ApiSuccess
Quick Reference Client Methods HTTP Method Client Method GET client.path.$get() POST client.path.$post() PUT client.path.$put() DELETE client.path.$delete() PATCH client.path.$patch() Request Options client.path.$method({ param: { id: '1' }, // Path parameters query: { page: 1 }, // Query parameters json: { name: 'Alice' }, // JSON body form: formData, // Form data header: { 'X-Custom': 'v' } // Headers })
Type Utilities import { InferRequestType, InferResponseType } from 'hono/client'
// Extract request type
type ReqType = InferRequestType
// Extract response type by status
type Res200 = InferResponseType
Related Skills hono-core - Framework fundamentals hono-validation - Request validation typescript-core - TypeScript patterns
Version: Hono 4.x Last Updated: January 2025 License: MIT