H
Hono•3w ago
Rasmus Lian

RPC types not working with imported Zod Object

Hi, When using Hono with RPC for inferring types on endpoints I have encountered a strange behaviour. Post payload & return types are working if I use z.object(), but not an imported z.object() (I call it TestSchema in the example below). I don't want to redefine types in two places, is there any way I can have an imported Zod Object in zValidator that gets inferred to the client with RPC? This works:
export const testRoutes = new Hono()
.use(adminMiddleware)
.post("/test", zValidator("json", z.object({
test: z.string()
})), async (c) => {
// logic
});
export const testRoutes = new Hono()
.use(adminMiddleware)
.post("/test", zValidator("json", z.object({
test: z.string()
})), async (c) => {
// logic
});
Results in this on client
(property) $post: (args: {
json: {
test: string;
};
}, options?: ClientRequestOptions) => Promise<ClientResponse<{}, StatusCode, string>>
(property) $post: (args: {
json: {
test: string;
};
}, options?: ClientRequestOptions) => Promise<ClientResponse<{}, StatusCode, string>>
This doesnt work:
// db/index.ts
export const TestSchema = z.object({
test: z.string(),
});
// db/index.ts
export const TestSchema = z.object({
test: z.string(),
});
// test.ts
import { TestSchema } from "@db/index.js";

export const testRoutes = new Hono()
.use(adminMiddleware)
.post("/test", zValidator("json", TestSchema), async (c) => {
// logic
});

// test.ts
import { TestSchema } from "@db/index.js";

export const testRoutes = new Hono()
.use(adminMiddleware)
.post("/test", zValidator("json", TestSchema), async (c) => {
// logic
});

Results in this on client
(property) $post: any
(property) $post: any
Thanks in advance
10 Replies
ambergristle
ambergristle•3w ago
that should work do you have access to the validated payload in the post handler?
Rasmus Lian
Rasmus LianOP•3w ago
@ambergristle Yes, the payload has correctly inferred types on the backend in the post handler. And it works to send data thru from client > backend succesfully. It is only that the types aren't shown in the client.
ambergristle
ambergristle•3w ago
roughly how many routes do you have? and what's your project setup? monorepo?
Rasmus Lian
Rasmus LianOP•3w ago
@ambergristle Yes, monorepo (only with two "apps", Hono backend and Next.js frontend) with pnpm workspaces. I don't know if its related to a tsconfig file or similar? Not many routes at all, I am just getting started with this project, maybe 10.
ambergristle
ambergristle•3w ago
are you importing the generated types (d.ts files), or just typeof app?
Rasmus Lian
Rasmus LianOP•3w ago
@ambergristle I export it like this
export type AppType = typeof routes;
export type AppType = typeof routes;
and import it like this, and construct an api client
import type { AppType } from "@backend";
import { hc } from "hono/client";
import { tokens } from "@/lib/auth/storage";

const baseUrl = "http://localhost:8080";

export const api = hc<AppType>(baseUrl, {
// Inject Bearer token from client-side token storage when available
fetch: async (input: URL | RequestInfo, init?: RequestInit) => {
try {
const stored = tokens.get();
const headers = new Headers(init?.headers as HeadersInit | undefined);

if (stored?.access) {
headers.set("Authorization", `Bearer ${stored.access}`);
}

// Preserve passed init but replace headers with our merged headers
const mergedInit = { ...(init || {}), headers };
return fetch(input, mergedInit);
} catch (err) {
// If anything goes wrong, fallback to default fetch
return fetch(input, init);
}
},
});
import type { AppType } from "@backend";
import { hc } from "hono/client";
import { tokens } from "@/lib/auth/storage";

const baseUrl = "http://localhost:8080";

export const api = hc<AppType>(baseUrl, {
// Inject Bearer token from client-side token storage when available
fetch: async (input: URL | RequestInfo, init?: RequestInit) => {
try {
const stored = tokens.get();
const headers = new Headers(init?.headers as HeadersInit | undefined);

if (stored?.access) {
headers.set("Authorization", `Bearer ${stored.access}`);
}

// Preserve passed init but replace headers with our merged headers
const mergedInit = { ...(init || {}), headers };
return fetch(input, mergedInit);
} catch (err) {
// If anything goes wrong, fallback to default fetch
return fetch(input, init);
}
},
});
ambergristle
ambergristle•3w ago
first step is probably to switch to using generated types. even if that doesn't fix the problem, it will preempt type issues as you scale this may also be helpful: https://hono.dev/docs/guides/rpc#compile-your-code-before-using-it-recommended
Rasmus Lian
Rasmus LianOP•3w ago
@ambergristle I'll try to swith to generated types, I'll let you know if it helped thx @ambergristle Generated types seemed to do the trick. I don't quite know why, but I'll guess I would've switched to that either way sooner or later. Thanks for your help
ambergristle
ambergristle•3w ago
think about it this way. when you do
// backend
export type AppType = typeof app;

// frontend
import type { AppType } from '/server'
// backend
export type AppType = typeof app;

// frontend
import type { AppType } from '/server'
every time your TS server wants to figure out what AppType is, it essentially recalculates from scratch, which means going down every route tree, through every validation schema, etc. when you try to do that import across projects, it becomes even more complex wheras generated types are static -> no computation. they just are whatever they need to be
Rasmus Lian
Rasmus LianOP•3w ago
Yeah I get that it is slower, I just thought it would work albeit slower. But seems like it is slower and didnt even work for some nested types. But I guess all good now 🙂

Did you find this page helpful?