Guide
Validating responses
This page shows how to check a response’s shape at runtime with a schema. Read it when you don’t control the backend, or when a changed field has broken a page before.
TypeScript trusts the type you write, and nothing checks it at runtime. When the backend changes a field, the page crashes three components later. Give the endpoint a schema, and the response is checked before you see it:
import { createApi, defineRequest } from 'liaise'
import { z } from 'zod'
const getUser = defineRequest()({
method: 'GET',
path: '/users/:id',
schema: z.object({ id: z.string(), name: z.string() }),
})
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
const { data, error } = await api.getUser({ id: '42' })
// ^? { id: string; name: string } | null
data is typed from the schema, as the comment under the call shows. If the backend starts sending id as a number, the call returns an error with kind: 'parse' instead of handing you a value that doesn’t match its type.
Valibot and ArkType work the same way:
import * as v from 'valibot'
import { type } from 'arktype'
const withValibot = defineRequest()({
method: 'GET',
path: '/users/:id',
schema: v.object({ id: v.string(), name: v.string() }),
})
const withArkType = defineRequest()({
method: 'GET',
path: '/users/:id',
schema: type({ id: 'string', name: 'string' }),
})
How a schema works
-
Any Standard Schema validator works. liaise doesn’t depend on any of them. Standard Schema is only an interface, so you bring the validator you already use.
-
The schema supplies the response type. You write no type argument, so there’s no second type to keep in sync.
-
datais the schema’s output. A schema that transforms changes what you receive, sodatacan differ from the raw response:const getUser = defineRequest()({ method: 'GET', path: '/users/:id', schema: z.object({ id: z.string(), createdAt: z.coerce.date(), // the wire sends a string role: z.string().default('user'), // absent on the wire }), }) const { data } = await api.getUser({ id: '42' }) data.createdAt // a real Date data.role // 'user' when the server left it out
When the response doesn’t match
-
A response the schema refuses is a
'parse'error. Nothing is thrown.error.bodyholds the validator’s issues, anderror.statusis the response’s own status, since the server answered fine:const { error } = await api.getUser({ id: '42' }) if (error?.kind === 'parse') { console.error(error.body) // the validator's issues } -
A validator that throws is a
'parse'error too, with the thrown value inerror.body. -
Only a 2xx body is validated. A non-2xx body is diagnostic and often a different shape, so it is left alone and reaches you as an
'http'error.
Handling errors shows a 'parse' branch next to the other kinds.
GraphQL
The GraphQL Operation (GraphQL) takes schema too. It validates the response’s whole data object, which is keyed by the fields the query selects, so the schema for query { me { id name } } describes { me: { id, name } }. There the response type stays explicit, because only defineRequest infers it:
import { Operation, gql } from 'liaise'
const UserSchema = z.object({ id: z.string(), name: z.string() })
const MeSchema = z.object({ me: UserSchema }) // data is { me: { id, name } }
const me = new Operation<Record<string, never>, z.infer<typeof MeSchema>>({
operation: gql`query { me { id name } }`,
schema: MeSchema,
})
A schema for the inner object alone, UserSchema here, would refuse every response, because data has no id at its top level.
Next: Cancelling, deadlines and stale requests ends calls you no longer need, or that take too long.