Chained routes
Make the cats API's routes part of its type by chaining them on the Hono instance, export that type, and watch a client built from nothing but the type call every route with autocompletion.
What you'll learn
- Chain routes on the Hono instance so typeof app carries every path, method, input and response
- Export AppType and build a typed client from it with hc(), sharing nothing but the type
- Read the error a client shows when the app it was built from was not chained
Every lesson so far has graded the cats API from the outside, and every client of it, a browser, a test, another service, has had to know its paths, its bodies and its statuses by reading the source and hoping. Hono's RPC feature closes that gap without a schema file, a code generator or a build step: the app's own TypeScript type describes every route, and a client built from that type calls them with autocompletion and refuses a wrong body before the request is sent. This lesson makes the cats API's type carry its routes.
A route is a type
app.get(path, handler) does two things. It registers the route, and it returns the app with that route added to its type: the path as a literal, the method, the input a validator declared, and what the handler answered with c.json(). Written as statements, the second half is thrown away:
const app = new Hono();
app.get('/posts', (c) => c.json(posts)); // the return value, and its type, are dropped
Written as a chain, the type accumulates:
const app = new Hono()
.get('/posts', (c) => c.json(posts))
.get('/posts/:id', (c) => c.json(post, 200))
.post('/posts', validator('json', check), (c) => c.json(created, 201));
export type AppType = typeof app;
typeof app is now a Hono whose schema says /posts answers a $get with Post[], /posts/:id takes a param named id and answers a Post, and /posts takes a $post whose json is what the validator returned. That is the whole feature: the type of an expression, exported.
The client
hc from hono/client builds a client from that type and a base URL. Nothing else crosses from server to client, no shared code, only an import type:
import { hc } from 'hono/client';
import type { AppType } from './app';
const client = hc<AppType>('http://localhost:8787');
const res = await client.posts.$get();
const posts = await res.json(); // Post[]
const one = await client.posts[':id'].$get({ param: { id: '7' } });
const created = await client.posts.$post({ json: { title: 'Hello' } });
A path segment is a property, a parameter segment is a bracketed key, [':id'], and the method is a $ call. Path parameters and query values are passed as strings, because that is what they are on the wire. The argument to $post is typed from the validator: forgetting json, or sending { title: 1 }, is a compile error, not a 400 at runtime. What comes back is a fetch Response, so res.status, res.headers and await res.json() are the usual ones, with json() typed by what the handler returned.
At runtime the client is a small proxy over fetch: client.posts[':id'].$get({ param }) becomes fetch('http://localhost:8787/posts/7'). The fetch it uses can be swapped, hc<AppType>(url, { fetch }), which is how the spec in this lesson calls the app with no server at all: fetch: app.request hands every request straight to the app, the same trick testClient from lesson 10 of Basics is built on.
What the type does not carry
A client built from an app that was not chained has nothing to work with. hc<typeof app> is then unknown, and the first call fails to compile:
client.spec.ts:12:23 - error TS18046: 'client' is of type 'unknown'.
The routes exist and answer requests; they are simply not in the type, because app.get(…) as a statement returned them into the void. The docs' testing page says the same for testClient: define routes by chaining, or the client knows nothing. One more shape loses information: a handler that returns a .then() chain instead of using async/await has a response type the client sees as unknown, which lesson 3 covers.
Your task
app.ts is the cats API from Basics, three routes registered one statement at a time, and client.spec.ts is a client built from its type, calling every route. The spec does not compile, because the type carries nothing.
- Chain the three routes on the
Honoinstance, as one expression, and keepexport default appfor the runtime. - Keep
export type AppType = typeof app, now with every route in it. - Run: the five tests call the API through the client, list, one by id, a 404, a refused body and a created cat.
Hover client in the spec before and after: the editor shows what the type knows.
When it fails
'client' is of type 'unknown'on every call in the spec: the routes are not chained. A route registered byapp.get(…);on a line of its own is invisible to the type.Property 'cats' does not exist on type …after chaining: a route is missing from the chain, or its path was mistyped. The type is exact:/catis not/cats.Property ':id' does not exist on type …: the parameter in the route is named differently; the client keys a parameter segment by the name in the path.- The spec compiles but
creates a catfails onexpected 201: the handler answersc.json(cat)without the status. The type does not check what a route answers at runtime; the tests do.
Remember
- Route methods return the app with the route in its type; chain them, and
typeof appis the API's contract. export type AppType = typeof app, thenhc<AppType>(baseUrl): the only thing shared is a type.client.segment[':param'].$method({ param, query, json }), strings on the wire, aResponseback withjson()typed.{ fetch: app.request }runs the client against the app in the same process, which is how tests use it.
Stuck? Show a hint
Every route method returns the app with that route added to its type, so write const app = new Hono().get(...).get(...).post(...) as one expression, then export type AppType = typeof app. The spec's client calls client.cats.$get(), client.cats[':id'].$get({ param }) and client.cats.$post({ json }).