Skip to content

Basic Usage ​

Every request returns a Go-style { data, error }: don't use try/catch to detect business failures (only network-level exceptions are thrown).

Creating a Client ​

typescript
import { createClient, eden } from '@vafast/api-client'
import { createApiClient } from './api.generated' // generated by the CLI, optional

const client = createClient({
  baseURL: 'https://api.example.com',
  timeout: 30_000,
  headers: { 'X-App-Id': 'my-app' },
})

// with generated types
const api = createApiClient(client)

// or a manual contract
// const api = eden<Api>(client)

Chained Calls ​

Only the last segment of the chain, the one that is invoked, is the HTTP verb; every property before it is treated as a path segment.

typescript
// GET /users?page=1
const { data, error } = await api.users.get({ page: 1 })

// POST /users
const created = await api.users.post({ name: 'Ada' })

// GET /users/123
const one = await api.users({ id: '123' }).get()

// PUT /users/123
const updated = await api.users({ id: '123' }).put({ name: 'Ada Lovelace' })

// DELETE /users/123
const removed = await api.users({ id: '123' }).delete()
CodeRequest
api.users.get(query?)GET /users
api.users.post(body)POST /users
api.users({ id }).get()GET /users/:id
api.users.find.post(body)POST /users/find

Caveat: Path Segments Named Like Verbs ​

A path may contain segments named get / post / put / patch / delete / head / options (e.g. POST /prices/delete). This library does not use prefixes like $post to avoid the clash. The convention is:

  1. A middle segment named delete is still just part of the path
  2. You must add the real HTTP verb as the final call in the chain
typescript
// POST /prices/delete
await api.prices.delete.post({ ids: ['1', '2'] })

// GET /reports/export
await api.reports.export.get({ format: 'csv' })

// DELETE /prices/delete (both the last path segment and the verb are delete)
await api.prices.delete.delete({ ids: ['1'] })
Easily confusedActual meaning
api.prices.delete()DELETE /prices (delete is treated as the verb)
api.prices.delete.post(...)POST /prices/delete (delete is the path, post is the verb)

Autocompletion and types already distinguish "path nodes" from "method definitions", so the second form in the table is suggested correctly. If a path segment really must be called delete or similar, remember: always follow the path segment with .get / .post / ….

Go-Style Error Handling ​

typescript
const { data, error } = await api.users.get({ page: 1 })

if (error) {
  // error: { code: number; message: string; type?: ErrorType; details?: ErrorDetail[] }
  console.error(`${error.code}: ${error.message}`)
  return
}

// data is non-null here
console.log(data.users)

Branch on the status code:

typescript
const { data, error } = await api.users.post(form)

if (error) {
  switch (error.code) {
    case 401:
      redirectToLogin()
      break
    case 403:
      showPermissionDenied()
      break
    case 422:
      // see validation errors below
      break
    default:
      showError(error.message)
  }
  return
}

console.log(data)

422 Schema Validation Errors ​

Consistent with the server: HTTP 422 + details. Use the helpers to bind them to a form:

typescript
import { isValidationError, mapDetailsToFormFields } from '@vafast/api-client'

const { error } = await api.users.post(formData)

if (error && isValidationError(error)) {
  formRef.setFields(mapDetailsToFormFields(error.details))
  return
}
error.typeMeaningTypical code
networkCannot connect0
timeoutTimed out408
abortCancelled0
server4xx / 5xxHTTP status code
parseFailed to parse the response0

Per-Request Config ​

Pass a RequestConfig as the second argument (e.g. cancellation, timeout, extra headers):

typescript
const controller = new AbortController()

const { data, error } = await api.users.get(
  { page: 1 },
  {
    signal: controller.signal,
    timeout: 5_000,
    headers: { Authorization: `Bearer ${token}` },
  },
)

// cancel
controller.abort()

Middleware ​

typescript
import { createClient, defineMiddleware, retryMiddleware, loggerMiddleware } from '@vafast/api-client'

const auth = defineMiddleware(async (ctx, next) => {
  const token = localStorage.getItem('token')
  if (token) ctx.headers.set('Authorization', `Bearer ${token}`)

  const res = await next()

  if (res.status === 401) {
    // token expired: refresh or redirect to login
  }
  return res
})

const client = createClient({ baseURL: '/api' })
  .use(auth)
  .use(retryMiddleware({ count: 3, delay: 1000 }))
  .use(loggerMiddleware({ prefix: '[API]' }))

SSE ​

Chain .sse() after a regular method (it goes through the same middleware):

typescript
const sub = api.chat.stream.post({ prompt: 'Hello' }).sse({
  onMessage: (chunk) => console.log(chunk),
  onError: (error) => console.error(error),
  onClose: () => console.log('done'),
})

sub.unsubscribe()

Full Example ​

typescript
import { createClient, isValidationError, mapDetailsToFormFields } from '@vafast/api-client'
import { createApiClient } from './api.generated'

const api = createApiClient(
  createClient({ baseURL: '/api', timeout: 30_000 }),
)

async function loadUsers(page: number) {
  const { data, error } = await api.users.get({ page })
  if (error) {
    showError(error.message)
    return null
  }
  return data
}

async function createUser(form: { name: string; email: string }) {
  const { data, error } = await api.users.post(form)
  if (error) {
    if (isValidationError(error)) {
      formRef.setFields(mapDetailsToFormFields(error.details))
      return null
    }
    showError(error.message)
    return null
  }
  return data
}

Next Steps ​