liaisev5.3.1
/Validating responses

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.

  • data is the schema’s output. A schema that transforms changes what you receive, so data can 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.body holds the validator’s issues, and error.status is 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 in error.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.

esc