InteractiveFrameworks

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.

  1. Chain the three routes on the Hono instance, as one expression, and keep export default app for the runtime.
  2. Keep export type AppType = typeof app, now with every route in it.
  3. 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 by app.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: /cat is 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 cat fails on expected 201: the handler answers c.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 app is the API's contract.
  • export type AppType = typeof app, then hc<AppType>(baseUrl): the only thing shared is a type.
  • client.segment[':param'].$method({ param, query, json }), strings on the wire, a Response back with json() 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 }).