This article is basically a tech note reflecting the things I got stack with.
What I got stuck with
When I was trying to use RPC for the first time with Hono, my hono client instance kept being inferred as type unknown
, even though I was sure that I was exporting the correct type from the server.
I'll leave brief information about Hono & RPC.
What is Hono?
Hono is a web framework mainly for building APIs with TypeScript. It is designed to be simple,fast, and easy to use.
It's reckon as a modern alternative to Express.js.
What is RPC?
RPC stands for Remote Procedure Call.
It is a protocol that one program can use to request a service from a program located in another computer in a network without having to understand the network's details.
In the context of web development, it is a way to call a function on a remote server as if it were a local function.
To be more specific & frank in here, it's a way to share API specifications between the server and the client.
Here's step by step summary of what I did.
Step1: Expenses
route for sample app.
I wrote a simple CRUD API for Expenses
in the server side with Hono, as I used to do with Express.
// server/routes/expenses.routes.ts
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import z from 'zod';
const expenseSchema = z.object({
id: z.number().int().positive().min(1),
name: z.string().min(3).max(255),
amount: z.number(),
});
type Expense = z.infer<typeof expenseSchema>;
const expenses: Expense[] = [
{ id: 1, name: 'Rent', amount: 1000 },
{ id: 2, name: 'Food', amount: 200 },
{ id: 3, name: 'Internet', amount: 50 },
];
const expensesRoute = new Hono();
expensesRoute.get('/', (c) => {
return c.json({ expenses: [] });
});
expensesRoute.post('/', zValidator('form', createPostSchema), async (c) => {
const expense = await c.req.json();
expenses.push(expense);
return c.json(expense);
});
expensesRoute.get('/:id', (c) => {
const id = parseInt(c.req.param('id'));
const expense = expenses.find((e) => e.id === id);
if (!expense) {
return c.notFound();
}
return c.json(expense);
});
expensesRoute.delete('/:id', (c) => {
const id = parseInt(c.req.param('id'));
const expense = expenses.find((e) => e.id === id);
if (!expense) {
return c.notFound();
}
const index = expenses.indexOf(expense);
const deletedExpense = expenses.splice(index, 1);
return c.json(deletedExpense);
});
export { expensesRoute };
Step2: Exporting the API type from server
// sever/index.ts
import { Hono } from 'hono';
import { expensesRoute } from './routes/expenses.routes';
// ...
const app = new Hono();
// ...
const apiRoutes = app.route('/expenses', expensesRoute);
export default app;
export type ApiRoutes = typeof apiRoutes;
Step3: Configuring the paths in client side
// client/tsconfig.json
{
"compilerOptions": {
/// ...
"paths": {
"@/*": ["./src/*"],
"@server/*": ["../server/*"]
},
/// ...
},
"include": ["src"]
}
// client/vite.config.ts
import path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
/// ...
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@server': path.resolve(__dirname, '../server'),
},
},
});
Step4: Importing the API type in the client
In this step, I found out that hono client instance was inferred as type unknown
.
// client/src/lib/api.ts
// I configured the
import { type ApiRoutes } from '@server/src';
import { hc } from 'hono/client';
const API_URL: string = import.meta.env.DEV
? 'http://localhost:8787'
: import.meta.env.VITE_API_URL;
const client = hc<ApiRoutes>(API_URL);
The Problem & Solution
The problem was in the step 1.
I defined each route indiviually and imperatively , like I used to do with Express.js.
The API itself was working fine with this way, but the type wasn't inferred correctly.
Bad ๐
const expensesRoute = new Hono();
expensesRoute.get('/', (c) => { ... });
expensesRoute.post('/', zValidator('form', createPostSchema), async (c) => { ... });
expensesRoute.get('/:id', (c) => { ... });
expensesRoute.delete('/:id', (c) => { ... });
export { expensesRoute };
I should have defined the routes declaratively, using method chain, as It's suggested in Documentation
Good ๐
export const expensesRoute = new Hono()
.get('/', (c) => { ... })
.post('/', zValidator('form', createPostSchema), async (c) => { ... })
.get('/total-spent', async (c) => { ... })
.get('/:id', (c) => { ... })
.delete('/:id', (c) => { ... });
After fixing this, the hono client instance was correcly inferred with all of the route definition.
Why it happened?
According to the TypeScript Documentation, type inference takes place when initializing variables and members, setting parameter default values, and determining function return types.
So when I defined the routes imperatively, and added it to expensesRoute
, TypeScript couldn't infer each of the type correctly. This is probably why the hono client instance was inferred as type unknown
.
Takeaways
Declarative over Imperative: When defining routes in Hono, it's better to use method chain to define the routes declaratively.
Type Inference: TypeScript infers types when initializing variables and members, setting parameter default values, and determining function return types. So, if you're having trouble with type inference, check if you're defining the types correctly in these places.
Top comments (1)
You just helped me after I spent hours trying to understand this!!!
Thanks a lot!