Statuses as types
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'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
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.
GET /catsreturns a.then()chain. Rewrite it withasync/await.GET /cats/:idandDELETE /cats/:idanswer a missing cat withc.notFound(). Answer{ error: "Cat <id> not found" }with an explicit404instead.- The successes are
c.json(cat)with no status. Say200, and keep the201and the204that 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 /catsstill returns a.then()chain.Type 'unknown' is not assignable to type '{}'atconst cat: CatResponse, andProperty 'error' does not exist on type 'unknown'in the 404 test: a branch ofGET /cats/:idstill answers withc.notFound().Type 'ContentfulStatusCode' is not assignable to type '200 | 404'atconst status: the success branch has no explicit200, so its member matches every status and the route's status type is all of them.removes a catfails withexpected 404: the second delete still answers throughc.notFound(), a404 Not Foundtext the test cannot read as JSON.
Remember
- Every
c.json(body, status)is one member of the route's type; write the status even for200. c.notFound()and a.then()chain give the clientunknown; answer{ error }with404, and useasync/await.InferResponseType<typeof client.x.$get, 200>names one member's body; without the second argument, the union.parseResponse()returns the body or throws aDetailedErrorcarryingstatusCodeanddetail.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.