Typed HTTP Clients
Learning Objectives
- You can refactor the working note operation to Hono’s typed client using the actual API router type.
- You can explain how request and response inference reveal client mismatches.
- You can explain the runtime failures that the type checker cannot prevent.
Our notes page already sends an explicit POST, handles rejection, and restores
stored records on reload. We will keep that behavior and its tests while changing
how createNote constructs the request. The form, callback, and parent-owned list
will continue to use the same operation.
In our current implementation, the client and server describe the same API separately. The server defines the route, HTTP method, expected request data, and response. The client repeats these details when constructing its request and describing the expected response. We must keep those descriptions aligned ourselves.
For example, changing the client’s URL to /api/note instead of /api/notes,
or writing JSON.stringify({ titel: title }), still produces valid TypeScript.
The checker does not know that these details disagree with the server. Tests or
running the application can reveal the mistake.
With Hono’s typed client, the server’s route definitions also supply the client’s request and response types. A misspelled route or request field can therefore produce a type error before we run the application. When the API changes, checking against its updated types helps us find client code that needs updating.
This reduces the information we maintain independently on both sides. Requests still travel over HTTP, and the server still validates them. Type checking helps detect disagreements about the API; runtime tests check how the application actually behaves.
A small typed request
First, read these two example modules. The greeting API uses the Hono, Zod, and
sValidator we already know. We will apply the idea to our notes API below.
import { Hono } from "hono";import { sValidator } from "@hono/standard-validator";import { z } from "zod";
export const app = new Hono().post( "/greetings", sValidator("json", z.object({ name: z.string().min(1) })), (c) => { const { name } = c.req.valid("json"); return c.json({ message: `Hello, ${name}!` }, 200); },);
export type GreetingApi = typeof app;GreetingApi captures the registered route, the validator’s input type, and the
JSON response type. Suppose this API is served at http://localhost:8000 and a
second module can import that type. Hono’s hc function creates its typed client:
import type { GreetingApi } from "./greeting-api.ts";import { hc } from "hono/client";
const client = hc<GreetingApi>("http://localhost:8000");const response = await client.greetings.$post({ json: { name: "Ada" } });
if (response.status === 200) { const greeting = await response.json(); console.log(greeting.message); // Hello, Ada!}greetings selects /greetings, $post selects POST, and json supplies its
body. The checker requires name to be a string and knows that the successful
response has a string message. It rejects json: { name: 123 } without
contacting the API. An empty string has the right type but still fails the
server’s validation.
The import type supplies information to the checker. Calling $post still
sends a real HTTP request; it does not call the server’s handler directly.
Hono’s RPC guide describes this connection
between route types and typed clients.
When the API changes
0 / 5 points
The greeting API changes its required JSON field from name to displayName. The client uses the updated GreetingApi type, but its call still contains json: { name: "Ada" }. What happens when we run the client’s type checker?
Preserve the inferred router type
The same practice-typed-http/ application includes a complete /examples/typed
page that uses client/src/lib/examples/notes/typed-note-api.ts; compare it with
fetch-note-api.ts in the same folder. Both pages use NotesExample.svelte, so
the difference is in the request module. The small exercise below applies the
same idea to one POST operation with the API and form already supplied.
Open api/src/routes/notes-routes.ts in practice-typed-http/. It is included in the
walking skeleton downloaded in Chapter 4.1,
and we inspected it in Chapter 4.3.
Its factory chains the GET and POST registrations into the value it returns.
At the end it exports:
export type NotesApi = ReturnType<typeof createNotesApp>;Here, ReturnType captures the app returned by the notes factory. The type
describes the actual route chain: /api/notes, its JSON input, and its
status-dependent responses. Registering routes on a broadly annotated Hono
variable can lose that information. The factory accepts a repository so the
route can be tested without opening a listener; the running app supplies the
PostgreSQL repository.
Import the type from the API source
The starter keeps api/ and client/ next to each other. Its supplied
client/tsconfig.json maps a short name to the actual notes router:
"paths": { "#api": ["../api/src/routes/notes-routes.ts"], "#api/*": ["../api/src/*"], "@src/*": ["../api/src/*"]}This lets a client module write import type { NotesApi } from "#api".
#api is a project alias, not an installed package. The wildcard #api/* mapping supports additional route modules.
The @src/* mapping lets the checker resolve the API’s own @src/ imports. The starter also enables
allowImportingTsExtensions so the checker understands the API’s .ts imports.
The practice application supplies these settings. The exercise also supplies its
type mapping, so neither requires configuration changes here.
How the containers share API source
Docker preserves the same sibling layout as /workspace/api and
/workspace/client. The client build uses ./api as an additional build context
to copy the source and install its pinned dependencies. During development, a
read-only bind mount makes edits in api/src/ immediately visible inside the
client container. The image therefore works without a source mount, and local
checks see the files you are editing.
The checker reads the route’s source type. import type disappears from the
browser JavaScript and does not execute the API or its database setup. There is
no second API interface or declaration-refresh command to maintain. The running
client still contacts the API over HTTP.
Let the router describe the request
For the notes API, the typed request has this shape:
const client = hc<NotesApi>(baseUrl, { fetch: transport });const response = await client.api.notes.$post({ json: { title } });hc constructs the method, path, content type, and JSON body. Here, api is the
/api path segment. Supplying the operation’s existing transport lets the same
client use the browser’s fetch or a controlled transport in a test.
The checker rejects json: { title: 123 } because the API expects a string. It
also detects a misspelled route or request field. These checks read types without
contacting the API. A string can still be empty, too long, or already stored;
the API decides validation and current-state rules when the request arrives.
Read the inferred response type
The response type also comes from the actual router. Narrowing its status lets us use the successful response:
if (response.status === 201) { const body = await response.json(); const acceptedId: number = body.note.id; console.log(acceptedId);}No annotation was added to body. The checker knows that body.note.id is a
number; assigning it to acceptedId: string would produce a type error. This
information must come from the imported router, rather than a second interface
that we manually keep aligned with the API.
Refactor a working note client
The exercise supplies a working API, form, and fetch-based client. Edit only the
client module: add a typed client factory and replace the POST request construction.
Keep the supplied GET, accepted-result handling, and error feedback. No server,
form, tests, or configuration need to be written. The card is the refactoring
practice for this chapter; you do not need to repeat it in /lab.
Refactor a working note client
0 / 10 points
A working application
This application already has a notes API, a form and a fetch-based client. Refactor one request to use the API’s inferred types. Allow about 15-20 minutes.
Edit only client/src/lib/api/note-api.ts. The other three editor files show
the supplied application; keep them unchanged:
api/src/notes-api.tsexportsNotesApifrom the actual Hono router.client/src/lib/components/NoteForm.sveltesubmits a title and shows feedback.client/src/routes/+page.svelteconnects the form to the client.
Dependencies, the #api import mapping and the grading host are supplied.
You do not need a local server, database, configuration changes or new tests.
Refactor the POST request
Import hc from hono/client and import the type NotesApi from #api.
Export a createNotesClient(baseUrl, transport) factory, with
transport: typeof fetch = fetch. It should return hc<NotesApi>(baseUrl, { fetch: transport }), preserving the inferred return type.
In createNote, replace the explicit fetch request with a call through that
factory to api.notes.$post({ json: { title } }). This is the only HTTP
operation to refactor. Keep the existing function parameters and all response
and error handling; leave getNotes, the public types and ApiError unchanged.
Use the actual exported API type. Do not copy its route types or bypass
inference with any, type-erasing casts or TypeScript suppression comments.
The supplied Note type still describes the operation’s public return value.
Check and submit
Choose Submit solution to run the checks and read their feedback. There
is no separate Run preview. Five points check that the factory follows the
actual API’s route, request and response types; five points check that
createNote sends its request through Hono’s client and preserves the supplied
behavior. Constructing an unused typed client beside the old fetch request
does not complete the refactor. You do not need to write type probes yourself.
The grading feedback separates two questions: does the client obtain useful
request and response types from the actual API, and does createNote send its
request through that client while preserving behavior? Merely creating an unused
typed client would leave the original fetch call unchecked.
Continue with the local notes page
For the later chapters, use the completed
client/src/lib/examples/notes/lab-note-api.ts supplied in the
walking skeleton. Copy it to
client/src/lib/api/note-api.ts in your existing practice-typed-http/
application. If your earlier download lacks this file, obtain the latest skeleton
and copy only this file; keep the form and page you developed in Chapters 4.3-4.4.
The continuation exports Note, ApiError, ErrorResponse, createNote, and
getNotes. It preserves field feedback and the abortable explicit GET; only the
POST uses Hono’s client. The /lab form, callback, and parent-owned list therefore
keep the same interface. At /lab, create a new note, reload to find it, and
submit its title again to observe duplicate feedback.
The supplied client checker reads both the client and its imported API source.
From practice-typed-http/, its command is:
docker compose run --rm --no-deps client deno task checkThe exercise runs its own checks when you submit. The local command serves the practice application; there is no additional test suite or refactor report to write here. Vite’s hot reload and production build do not replace type checking. API source edits need no declaration generation or client image rebuild. Rebuild the client after changing dependencies or configuration.
Type checks and runtime tests
Figure 1 shows how the router supplies source types to the checker while the running form sends HTTP requests through the API to PostgreSQL.
Expected rejections, unreadable responses, and network failures still need handling. Types do not validate arbitrary response bytes.
Keep Chapter 4.4’s raw app.request() bypass test and the supplied database
integration test. A caller can send invalid JSON without going through hc.
The browser observation adds evidence that the running client, CORS settings,
API, and database cooperate; the type checker and controlled transport test
cannot establish that on their own.
The marketplace’s typed-client milestone applies this refactor to your own API. It includes one real response-field change so you can observe the checker follow the server source without adding a second response interface.
Check Your Understanding
- How does importing the actual API route type reveal a client mismatch before any HTTP request is sent?
- Why can a correctly typed note submission still receive 409?
- Which observations show that the refactor preserved the working browser workflow?