The client
Wrap hc in a module the rest of an app can call: list(), find() and add() over the typed client, statuses narrowed into a Cat, a null or an ApiError, and a base URL that tests and production both pass in.
What you'll learn
- Narrow a client response on res.status so json() is typed as the body of that status
- Wrap hc in a module that takes the base URL and ClientRequestOptions, so a test can inject fetch
- Turn an API's refusal into a return value or an error that carries the status and the message
Lesson 1 built a client in a test and called it inline. Real code wraps it: a page, a CLI or another service wants cats.find(2) and a Cat or null back, not a Response to inspect, and it wants one place where the base URL, the credentials and the error handling live. This lesson writes that module for the cats API, on top of hc. It is the shape the docs' RPC guide leads to: the client is generated from the type, and the module around it is yours.
A response with a status the type knows
The API answers GET /cats/:id with a cat or with { error }. The client sees both in one Response type whose status is a union, 200 | 404, because the handler wrote both statuses down:
if (!post) return c.json({ error: 'not found' }, 404);
return c.json({ post }, 200);
Checking the status narrows the response, and with it what json() gives:
const res = await client.posts[':id'].$get({ param: { id } });
if (res.status === 404) {
const { error } = await res.json(); // { error: string }
return null;
}
const post = await res.json(); // { post: Post }
res.ok narrows the same way, true for the success member and false for the rest. What makes this work is the explicit status in every c.json(); lesson 3 shows what the client sees without it.
The module around the client
export function createPostsClient(baseUrl: string, options?: ClientRequestOptions) {
const client = hc<AppType>(baseUrl, options);
return {
async find(id: number): Promise<Post | null> {
const res = await client.posts[':id'].$get({ param: { id: String(id) } });
if (res.status === 404) return null;
const { post } = await res.json();
return post;
},
};
}
Three decisions are in those lines. The function takes the base URL and the client options, ClientRequestOptions from hono/client, and passes them through, so one module serves a browser (the real fetch, an origin) and a test ({ fetch: app.request }, any origin). Numbers become strings at the boundary, String(id), because a path parameter is text. And the refusal becomes a value the caller expects, null, instead of a status the caller would have to know about.
When the API refuses with a message worth passing on, the module throws, and the error carries what the API said, status and message both:
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
const res = await client.posts.$post({ json: draft });
if (res.status === 201) return res.json();
const { error } = await res.json();
throw new ApiError(res.status, error);
After if (res.status === 201) return …, the type of res is what remains, the 400 member, so error is typed and res.status is 400. The caller writes try { await posts.add(draft) } catch (e) { if (e instanceof ApiError && e.status === 400) … } and never sees a Response.
Where the requests go
hc(baseUrl) joins the base URL and the route's path: hc<AppType>('https://api.example/v1') sends client.posts.$get() to https://api.example/v1/posts. The base URL is the one thing that differs between a test, a local run and production, which is why the module takes it as an argument instead of importing a constant. A relative base such as '/api' works in a browser page, whose fetch resolves it against the page's origin; Node's fetch refuses it with Failed to parse URL, and $url(), in lesson 4, needs an absolute one.
Your task
app.ts is the chained cats API with explicit statuses and a factory, createApp(), so every test gets fresh cats. cats-client.ts has the ApiError class and three functions that throw not written yet.
- Build the typed client from
AppTypeincreateCatsClient, with the base URL and the options it was given. list()gives every cat.find(id)gives the cat, ornullon a 404.add(cat)gives the created cat on 201, and throws anApiErrorwith the API's status and message otherwise.
The spec calls the module with a fresh app's request as its fetch, and once with a fetch that records the URLs, to see the base URL in use.
When it fails
Type '{ error: string; }' is not assignable to type 'Cat'infind: the 404 was not narrowed away beforereturn res.json(). Checkres.statusfirst; the type follows the check.Property 'error' does not exist on type 'Cat'inadd: the check is the other way round, soresafter it still includes the success. Return on201first; what is left is the refusal.throws an ApiErrorfails withExpected the promise to reject, but it resolved:addreturned the error body as if it were a cat. A refusal has to be thrown, not returned.sends every request to the base URLsees/catsinstead of the full URL: the base URL never reachedhc, or a constant replaced it.
Remember
- Explicit statuses in
c.json()makeres.statusa union the client narrows on;res.oknarrows too. - Wrap
hcin a module that takes the base URL andClientRequestOptions, converts numbers to strings, and turns refusals intonullor anApiError. - Return on the success status first; what remains of
resis the refusal, typed. - The base URL is an argument, never a constant, so tests and production share the module.
Stuck? Show a hint
const client = hc<AppType>(baseUrl, options). list(): const res = await client.cats.$get(); return res.json(). find(): $get({ param: { id: String(id) } }), null on res.status === 404. add(): $post({ json: cat }); return res.json() when res.status === 201, otherwise read { error } and throw new ApiError(res.status, error).