Files
What you'll learn
- Build a client to another service from its exported type, with a fetch that is a binding, the network, or the other app's request()
- Call a service from inside a handler and let its types flow into the gateway's own response
- Narrow on the service's status and answer with the same status to pass a refusal through
Service to service
A typed client is not only for browsers. On Cloudflare, one Worker calls another through a service binding; on Node, one process calls another over HTTP; either way the caller is a Hono app itself, and it can hold a client built from the other service's type. This lesson builds a gateway in front of the cats service: a dashboard that aggregates what the service knows, and an adoption route that forwards a request and passes the service's refusals through. The service is a second Hono app in the same worker, and the gateway talks to it the way it would across the network.
The fetch is the network
const cats = hc<CatsService>('http://cats.internal', { fetch: service.request });
hc builds requests and hands them to a fetch. Which fetch decides where they go: the platform's, to a URL; a service binding's, c.env.CATS.fetch.bind(c.env.CATS) in the guide's custom-fetch example; or, as here, the other app's own request(), which runs it in the same process. The client code does not change between the three, and neither does its type, which comes from export type CatsService = typeof service in the service's own file. The base URL still matters: the path is built on it, a binding ignores the host, and $url() needs it to be absolute.
Building the client at module level, once, is right: it holds no connection, only the base URL and the options.
A handler that is also a client
.get('/dashboard', async (c) => {
const res = await cats.cats.$get();
const all = await res.json(); // Cat[], from the service's type
return c.json({ total: all.length }, 200);
})
Two contexts meet in one handler, and it helps to keep them apart in the names: c is the request the gateway is answering, res is the service's answer. The service's type flows through: all is Cat[], and all.filter((cat) => !cat.adopted) is checked against the service's Cat, not a copy the gateway maintains. When the service changes its shape, the gateway fails to compile instead of failing at runtime, which is the point of sharing the type across the boundary.
Passing refusals through
The service answers an adoption with 200 and the cat, 404 when there is no such cat, 409 when it is already adopted, and the gateway should not flatten those into one error:
.post('/adopt/:id', async (c) => {
const res = await cats.cats[':id'].adopt.$post({ param: { id: c.req.param('id') } });
if (res.status === 200) {
const cat = await res.json();
return c.json({ message: `${cat.name} has a home` }, 200);
}
const { error } = await res.json();
return c.json({ error }, res.status);
})
After the 200 branch returns, res.status is 404 | 409, which is a valid status for c.json(), so the gateway answers with the service's own status and message. The gateway's type, in turn, records 200 | 404 | 409 for /adopt/:id, and its own clients narrow on them the same way. parseResponse() would do the reading, at the cost of a try/catch around a DetailedError whose statusCode is any; narrowing on res.status keeps the types all the way through.
Your task
cats-service.ts is given, chained, with three cats and its type exported. main.ts is the gateway, with a client to build and two routes that answer 501.
- Build the client from
CatsService, with the service'srequestas itsfetch. GET /dashboardanswers{ total, adoptable, oldest }: the number of cats, the names of those not adopted, the name of the oldest.POST /adopt/:idforwards to the service's adoption route;{ message: "<name> has a home" }on success, the service's{ error }with the service's status otherwise.
Send POST /adopt/1 twice from the request bar and read the second answer.
When it fails
'all' is of type 'unknown'orProperty 'adopted' does not exist: the client was built withoutCatsService, or fromtypeofan app that was not chained.GET /dashboardanswers500and the console showsUnexpected token '4', "404 Not Found": the client's route does not match the service, which answered its 404 page, and the gateway tried to read it as JSON.POST /adopt/1the second time answers200withundefined has a home: the gateway read the cat without checking the status first; the409body has noname.Property 'adopt' does not exist on type …: the service's route is/cats/:id/adopt, so the client path iscats[':id'].adopt; a segment after a parameter is a property again.
Remember
hc<ServiceType>(baseUrl, { fetch }): the type from the service's file, thefetchfrom wherever the service is, a binding, the network, or the app itself.- In a handler,
cis the request in andresthe service's answer; the service's types flow into the handler. - Narrow on
res.status, and answer with the same status to pass a refusal through. - Build clients once at module level; they hold no connection.
Stuck? Show a hint
const cats = hc<CatsService>('http://cats.internal', { fetch: service.request }). Dashboard: const all = await (await cats.cats.$get()).json(), then filter the adoptable, pick the oldest by age. Adopt: cats.cats[':id'].adopt.$post({ param: { id: c.req.param('id') } }); on 200 answer { message: `${cat.name} has a home` }, otherwise read { error } and answer it with res.status.
Press Run tests to start the app. Its log appears here.Graded endpoints
The gateway asked the service for every cat and summarised: three cats, two not adopted, Luna the oldest
Forwarded to the service, which answered 200 with the cat; the gateway answers its own message
The service refused with 409; the gateway passes the status and the message through
The service's 404, passed through the same way
The service's state changed, and the gateway reads it fresh on every request