Forms & Mutations

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.

greeting-api.ts
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:

greeting-client.ts
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.ts exports NotesApi from the actual Hono router.
  • client/src/lib/components/NoteForm.svelte submits a title and shows feedback.
  • client/src/routes/+page.svelte connects 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:

Terminal window
docker compose run --rm --no-deps client deno task check

The 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.

Figure 1. Source types inform the checker; the running API still validates and stores each request.

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

  1. How does importing the actual API route type reveal a client mismatch before any HTTP request is sent?
  2. Why can a correctly typed note submission still receive 409?
  3. Which observations show that the refactor preserved the working browser workflow?