liaisev5.3.1
/Quick start

Getting started

Quick start

New here? Watch the 3½-minute intro.

This page takes you from install to a first typed call. Start here when you want to try liaise in a project; The problem it solves says why you might.

Install

npm install liaise

liaise has no dependencies and uses the standard fetch. CI tests it on Node 20, 22 and 24, and Where it runs covers browsers, Bun, Deno, Cloudflare Workers and React Native.

  • It is ESM only. import it. require('liaise') fails with ERR_PACKAGE_PATH_NOT_EXPORTED, so from CommonJS use await import('liaise').
  • Set TypeScript’s moduleResolution to bundler, node16 or nodenext. liaise has three entry points, and the old node setting finds only the first, so liaise/middleware and liaise/testing don’t resolve.
  • liaise is built and tested with TypeScript 5.8. CI also checks that its types compile on every TypeScript version from 4.7 to 6.0.

Coming from @iremlopsum/apify? That is liaise’s old name. Switching takes two steps, listed in MIGRATION.md.

Make your first call

Define two endpoints, create a client, and make a call. https://api.example.com stands for your API’s base URL.

import { createApi, defineRequest } from 'liaise'

type User = { id: string; name: string; email: string }

// 1. Describe your endpoints. The path decides which params are required.
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
const createUser = defineRequest<User, { name: string; email: string }>()({
  method: 'POST',
  path: '/users',
})

// 2. Create the client. There is no default timeout: without one, a server
//    that never answers keeps the call waiting.
const api = createApi({
  baseUrl: 'https://api.example.com',
  requests: { getUser, createUser },
  timeout: 10_000,
})

// 3. Call it. This never throws: you always get { data, error }.
const { data, error } = await api.getUser({ id: '42' })

if (error) {
  // error.kind says what went wrong: 'http', 'network', 'timeout', ...
  console.error(error.kind, error.status)
} else {
  console.log(data.name) // data is a User here
}

// A POST sends its params as a JSON body.
const created = await api.createUser({ name: 'Grace', email: 'grace@example.com' })
if (!created.error) console.log(created.data.id)

defineRequest<User>()({ ... }) is two calls on purpose. The first takes the response type you write, and the second infers the params from the path. Defining endpoints explains why it can’t be one.

What you just got

  • The path decides the params. /users/:id makes id required, so a wrong name doesn’t compile:

    // @ts-expect-error: the path names the param `id`, not `userId`
    api.getUser({ userId: '42' })

    A call whose id is still undefined at runtime returns an error before anything is sent.

  • Every other param comes from the second type. createUser takes { name: string; email: string }. It is a POST, so it sends them as a JSON body.

  • A hung server becomes an error only with a timeout. There is no default, so the example sets 10 seconds on the client. When it passes, the call comes back with kind: 'timeout' (Set a deadline).

  • data is typed from defineRequest<User>. You never annotate a call.

  • Nothing throws, not even when you’re offline. Every failure comes back in error, and error.kind names it: 'http', 'network', 'timeout', 'abort', 'parse' or 'middleware'.

  • Checking error first narrows data to User, so you never write data!.

Next: How it fits together names the pieces you just used and follows one call through them. If you have a task in mind, go straight to it:

esc