Type-safe clients and testing
Test the contract, not the types
Add runtime negative tests, compatibility examples, and response assertions around the inferred client.
Types catch mistakes while you type. Deployed clients can be old, untyped, or wrong. Runtime validation and HTTP tests are what protect the boundary.
The suite checks error shapes, status codes, headers, and unknown fields using raw requests, not the typed client:
it('rejects unknown JSON fields when policy is strict', async () => {
const res = await app.request('/bookmarks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'Hono docs',
url: 'https://hono.dev',
admin: true
})
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({ error: expect.any(String) })
})
it('rejects malformed JSON', async () => {
const res = await app.request('/bookmarks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{ title: '
})
expect(res.status).toBe(400)
})
Vitest output for a failing contract test is explicit:
FAIL bookmarks API > rejects unknown JSON fields
AssertionError: expected 201 to be 400
That means someone removed .strict() or the validator hook without updating tests. The typed RPC client would still compile.
Mirror the same case with curl:
curl -s -w '\n%{http_code}\n' -X POST http://localhost:3000/bookmarks \
-H 'content-type: application/json' \
-d '{"title":"Hono docs","url":"https://hono.dev","admin":true}'
You want 400 and a JSON error body, not 201 with a bookmark that picked up a surprise field.
Write at least one test that cannot be expressed as a valid typed client call. Send wrong Content-Type, empty body, or plain text where JSON is required.
Do not delete runtime validation because the in-repo client compiles. Mobile apps and webhooks do not share your type checker.
If the contract requires x-request-id on every response, assert it in raw app.request() tests, not only inside the RPC wrapper.
A realistic failure: tests use the typed client exclusively, so a renamed route ships with green CI while curl gets 404. Add one raw HTTP test per route that bypasses hc.
Save curl one-liners in test file comments so operators can reproduce Vitest failures without reading the whole suite.
Pagination and header contracts deserve the same treatment. If list endpoints cap limit at 100, send ?limit=1000 with curl and assert 400.
The typed client cannot express every hostile payload. Raw HTTP tests are how you prove runtime validation still guards the server after refactors.
Try this on your own project: add three negative tests that bypass the RPC client entirely and document the expected 4xx body for each.
Lesson completed