Statuses as typesRPC · Hono

Three habits that answer correctly and tell the client nothing: a .then() chain, c.notFound(), and c.json() without a status. Fix each on a cats API that reads from an asynchronous store, and read a refusal with parseResponse.

What you will learn

Read the theory for Statuses as types

All RPC lessons

All Hono courses

loading types…

What you'll learn

  • Write every status in c.json() so each becomes a member of the route's response type
  • Answer a missing resource with c.json({ error }, 404) instead of c.notFound(), which the client sees as unknown
  • Use async/await in handlers rather than a .then() chain, and read a body or a refusal with parseResponse and DetailedError

Statuses as types

The client of lesson 2 could tell a cat from a refusal because every c.json() in the API named its status. The docs' RPC guide gives this one line, "specify the status code", and it is easy to skip: the API works exactly the same without it. What changes is the type. This lesson shows three ways a handler can answer correctly at runtime and tell the client nothing, then fixes all three on a cats API that now reads from an asynchronous store, the way a real one reads from a database.

What a status adds to the type

Each c.json(body, status) in a handler becomes one member of the route's response type, with its body, its status and its format. Two returns, two members:

if (!post) return c.json({ error: 'not found' }, 404);
return c.json({ post }, 200);

The client's res.status is 404 | 200, checking it narrows res, and InferResponseType picks one member by status:

import type { InferResponseType } from 'hono/client';

type PostResponse = InferResponseType<typeof client.posts[':id']['$get']>; // { error: string } | { post: Post }
type PostFound = InferResponseType<typeof client.posts[':id']['$get'], 200>; // { post: Post }

Without the status, c.json({ post }) alone, that member's status is "any status a body can have", a union of every code from 100 to 511. It still narrows to itself on res.status === 200, but it survives every other check too: res.status === 404 keeps it, because 404 is in its union, so the 404 branch's json() is { error } | { post }, InferResponseType<…, 404> is the same, and res.status cannot be assigned to 200 | 404. One member without a status blurs every other member's.

c.notFound() knows nothing

c.notFound() is convenient in a handler: it runs the app's notFound handler and answers with whatever that returns. That is exactly the problem for the client. The type of that response is {} with a format of string, and once one branch of a handler is that, res.json() on every branch of the route is Promise<unknown>. The guide's advice is plain: "you should not use c.notFound() for the Not Found response … Please use c.json() and specify the status code." The same care applies to answering one branch with c.text() and another with c.json(): the client must narrow before it knows which reader to call.

A handler that returns a promise chain

.get('/posts', (c) => db.all().then((posts) => c.json(posts, 200))) // the client sees unknown
.get('/posts', async (c) => c.json(await db.all(), 200)) // Post[]

The first handler's return type is inferred through .then(), and Hono's route types cannot see into it. This is listed under the guide's known issues, and the cure is async/await, which makes the handler's return type what c.json() said.

parseResponse

When the caller only wants the body and is happy for a refusal to throw, parseResponse from hono/client does the checking:

import { parseResponse, DetailedError } from 'hono/client';

const post = await parseResponse(client.posts[':id'].$get({ param: { id } }));

It awaits the response, reads it as JSON or text by its content type, and returns the body when res.ok. Otherwise it throws a DetailedError whose statusCode is the status and whose detail.data is the body the API sent, so { error: 'not found' } is still there for the caller. Its message is the status and the status text, which for a response built by script is 404 with nothing after it, so read statusCode and detail rather than the message.

Your task

app.ts is chained, and the spec's client compiles against it except for three habits the type cannot follow, each marked with a TODO. store.ts is the asynchronous store the routes read.

  1. GET /cats returns a .then() chain. Rewrite it with async/await.
  2. GET /cats/:id and DELETE /cats/:id answer a missing cat with c.notFound(). Answer { error: "Cat <id> not found" } with an explicit 404 instead.
  3. The successes are c.json(cat) with no status. Say 200, and keep the 201 and the 204 that are already there.

The spec narrows on res.status, names the found cat's type with InferResponseType<…, 200>, and reads a cat and a refusal through parseResponse.

When it fails

  • 'cats' is of type 'unknown' in the list test: GET /cats still returns a .then() chain.
  • Type 'unknown' is not assignable to type '{}' at const cat: CatResponse, and Property 'error' does not exist on type 'unknown' in the 404 test: a branch of GET /cats/:id still answers with c.notFound().
  • Type 'ContentfulStatusCode' is not assignable to type '200 | 404' at const status: the success branch has no explicit 200, so its member matches every status and the route's status type is all of them.
  • removes a cat fails with expected 404: the second delete still answers through c.notFound(), a 404 Not Found text the test cannot read as JSON.

Remember

  • Every c.json(body, status) is one member of the route's type; write the status even for 200.
  • c.notFound() and a .then() chain give the client unknown; answer { error } with 404, and use async/await.
  • InferResponseType<typeof client.x.$get, 200> names one member's body; without the second argument, the union.
  • parseResponse() returns the body or throws a DetailedError carrying statusCode and detail.data.
Stuck? Show a hint

GET /cats: async (c) => { const cats = await store.all(); return c.json(cats, 200); }. A missing cat: return c.json({ error: `Cat ${id} not found` }, 404). Every success: c.json(cat, 200). The 201 and the 204 stay as they are.