rpc-typesafe
Scannednpx machina-cli add skill smicolon/ai-kit/rpc-typesafe --openclawHono RPC Type Safety
Patterns for type-safe client-server communication with Hono.
Server Setup
Export App Type
// src/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
// Define routes with validators for full type inference
const routes = app
.get('/users', async (c) => {
return c.json({ users: [{ id: '1', name: 'John' }] })
})
.post('/users',
zValidator('json', z.object({
email: z.string().email(),
name: z.string()
})),
async (c) => {
const data = c.req.valid('json')
return c.json({ id: crypto.randomUUID(), ...data }, 201)
}
)
.get('/users/:id', async (c) => {
const id = c.req.param('id')
return c.json({ id, name: 'John' })
})
export default routes
// CRITICAL: Export type for client
export type AppType = typeof routes
Route Chaining for Type Inference
// Chain routes to preserve types
const userRoutes = new Hono()
.get('/', async (c) => c.json({ users: [] }))
.post('/',
zValidator('json', createUserSchema),
async (c) => c.json({ id: '1' }, 201)
)
const postRoutes = new Hono()
.get('/', async (c) => c.json({ posts: [] }))
.post('/',
zValidator('json', createPostSchema),
async (c) => c.json({ id: '1' }, 201)
)
// Compose and export
const app = new Hono()
.route('/users', userRoutes)
.route('/posts', postRoutes)
export type AppType = typeof app
Client Setup
Basic Client
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'
// Create typed client
const client = hc<AppType>('http://localhost:8787')
// Type-safe requests
const res = await client.users.$get()
const data = await res.json() // Typed!
Client Factory
// lib/api-client.ts
import { hc } from 'hono/client'
import type { AppType } from '@/server'
export function createClient(baseUrl: string, token?: string) {
return hc<AppType>(baseUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
})
}
// Default instance
export const api = createClient(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8787'
)
Request Patterns
GET Requests
// Simple GET
const res = await api.users.$get()
const { users } = await res.json()
// GET with query params
const res = await api.users.$get({
query: {
page: 1,
limit: 20,
search: 'john'
}
})
// GET with path params
const res = await api.users[':id'].$get({
param: { id: 'user-123' }
})
POST Requests
// POST with JSON body
const res = await api.users.$post({
json: {
email: 'user@example.com',
name: 'New User'
}
})
if (res.ok) {
const user = await res.json()
console.log(user.id) // Typed!
}
PUT/PATCH Requests
const res = await api.users[':id'].$put({
param: { id: 'user-123' },
json: {
name: 'Updated Name'
}
})
DELETE Requests
const res = await api.users[':id'].$delete({
param: { id: 'user-123' }
})
if (res.status === 204) {
console.log('Deleted successfully')
}
Type Utilities
InferRequestType / InferResponseType
import { hc, InferRequestType, InferResponseType } from 'hono/client'
import type { AppType } from './server'
// Get specific endpoint type
type CreateUserRequest = InferRequestType<typeof api.users.$post>['json']
// { email: string; name: string }
type UserResponse = InferResponseType<typeof api.users[':id'].$get>
// { id: string; name: string }
// Use in functions
async function createUser(data: CreateUserRequest): Promise<UserResponse> {
const res = await api.users.$post({ json: data })
return res.json()
}
URL Generation
// Generate URL without making request
const url = api.users[':id'].$url({
param: { id: 'user-123' }
})
// 'http://localhost:8787/users/user-123'
// Useful for links, prefetching, etc.
Error Handling
Response Status Checking
async function getUser(id: string) {
const res = await api.users[':id'].$get({ param: { id } })
if (!res.ok) {
if (res.status === 404) {
throw new Error('User not found')
}
throw new Error(`API error: ${res.status}`)
}
return res.json()
}
Typed Error Responses
// Server defines error format
app.get('/users/:id', async (c) => {
const user = await getUser(c.req.param('id'))
if (!user) {
return c.json({ error: 'User not found' }, 404)
}
return c.json(user)
})
// Client handles typed errors
const res = await api.users[':id'].$get({ param: { id } })
if (res.status === 404) {
const { error } = await res.json()
console.log(error) // 'User not found'
}
React Query Integration
Query Hooks
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
// List query
export function useUsers(page = 1) {
return useQuery({
queryKey: ['users', page],
queryFn: async () => {
const res = await api.users.$get({ query: { page } })
if (!res.ok) throw new Error('Failed to fetch users')
return res.json()
}
})
}
// Single item query
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: async () => {
const res = await api.users[':id'].$get({ param: { id } })
if (!res.ok) throw new Error('User not found')
return res.json()
},
enabled: !!id
})
}
Mutation Hooks
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: { email: string; name: string }) => {
const res = await api.users.$post({ json: data })
if (!res.ok) throw new Error('Failed to create user')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
}
})
}
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: { name: string } }) => {
const res = await api.users[':id'].$put({ param: { id }, json: data })
if (!res.ok) throw new Error('Failed to update user')
return res.json()
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['users', id] })
}
})
}
Component Usage
function UserList() {
const { data, isLoading, error } = useUsers()
const createUser = useCreateUser()
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
{data?.users.map(user => (
<div key={user.id}>{user.name}</div>
))}
<button
onClick={() => createUser.mutate({
email: 'new@example.com',
name: 'New User'
})}
>
Add User
</button>
</div>
)
}
Configuration Requirements
TypeScript Config
Both server and client need strict mode:
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler"
}
}
Status Codes for Type Discrimination
// Server: Explicit status codes enable type inference
app.post('/users', async (c) => {
return c.json({ id: '1', name: 'User' }, 201) // 201 for created
})
app.get('/users/:id', async (c) => {
const user = await getUser(id)
if (!user) {
return c.json({ error: 'Not found' }, 404) // 404 for not found
}
return c.json(user, 200) // 200 for success
})
Best Practices
- Always export
AppTypefrom server - Chain routes for proper type inference
- Use
zValidatorfor automatic request type inference - Specify status codes for response type discrimination
- Use
strict: truein both server and client tsconfig - Create typed error handlers for consistent error responses
- Wrap in React Query for caching and state management
- Use
InferRequestType/InferResponseTypefor complex types
Source
git clone https://github.com/smicolon/ai-kit/blob/main/packs/hono/skills/rpc-typesafe/SKILL.mdView on GitHub Overview
Provides patterns for type-safe client-server communication with Hono. It covers exporting AppType from server routes, preserving types through route chaining, and creating a typed hc client with InferRequestType and InferResponseType, with optional React Query integration.
How This Skill Works
On the server, routes are defined with validators and an AppType is exported to capture the full endpoint surface. On the client, a typed client is created with hc<AppType>(baseUrl), and InferRequestType/InferResponseType are used to derive strongly-typed request and response types, enabling compile-time safety across calls and, optionally, React Query integration.
When to Use It
- You want end-to-end type safety for RPC calls between a Hono server and a TypeScript client.
- You need to export a single AppType surface from the server for client consumption.
- You want to preserve types when composing routers via route chaining.
- You want to infer precise request/response types using InferRequestType and InferResponseType.
- You plan to integrate typed API calls with React Query for data fetching.
Quick Start
- Step 1: Define routes on the server and export AppType representing the endpoints.
- Step 2: On the client, create a typed client with hc<AppType>(baseUrl) (or via a factory).
- Step 3: Call endpoints (e.g., const res = await client.users.$get()) and read typed data from res.json().
Best Practices
- Export AppType from the server (e.g., typeof routes or typeof app) to be the single source of truth for the client.
- Use zod validators (zValidator) to anchor request shapes and preserve full type inference.
- Prefer route chaining to maintain type information across modules and exports.
- Leverage InferRequestType / InferResponseType for strong-typed helpers and utilities.
- Centralize client creation in a factory (e.g., createClient) and reuse the typed hc client across the app.
Example Use Cases
- Server exports AppType and the client imports AppType to create a typed hc client.
- Basic typed client usage: const client = hc<AppType>(baseUrl); const res = await client.users.$get();
- GET/POST with path and query params using the typed client (e.g., api.users[':id'].$get({ param: { id: 'user-123' } })).
- Define helper functions using InferRequestType to type function parameters and InferResponseType for return types.
- Integrate the typed client with React Query to fetch and cache type-safe API data.