Get the FREE Ultimate OpenClaw Setup Guide →

rpc-typesafe

Scanned
npx machina-cli add skill smicolon/ai-kit/rpc-typesafe --openclaw
Files (1)
SKILL.md
8.2 KB

Hono 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

  1. Always export AppType from server
  2. Chain routes for proper type inference
  3. Use zValidator for automatic request type inference
  4. Specify status codes for response type discrimination
  5. Use strict: true in both server and client tsconfig
  6. Create typed error handlers for consistent error responses
  7. Wrap in React Query for caching and state management
  8. Use InferRequestType/InferResponseType for 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

  1. Step 1: Define routes on the server and export AppType representing the endpoints.
  2. Step 2: On the client, create a typed client with hc<AppType>(baseUrl) (or via a factory).
  3. 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.

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers