Skip to content

json-api-ts

A typesafe, JSON:API-flavored TypeScript client, generated from the OpenAPI 3.1 document your API already serves. Point the codegen at your spec, commit the emitted client, and every read and write is typed end to end — ?include hydrates the requested relations into the result type, sparse fields narrow it, and the wire envelope is materialised into clean, flat resource objects.

See it running

The spotify-clone example is hosted live on this site — a full React + TanStack Query app running backend-free on the generated client's mock transport.

Lineage

The OpenAPI document is the contract. Any backend built on haddowg/json-api emits it (with the per-type JSON Schemas) — the Symfony bundle, the Laravel package, or the core wired into your own stack. This repo consumes it — the codegen reads it, and a generic, framework-agnostic runtime is parameterised by it.

haddowg/json-api            (core PHP library — framework-agnostic JSON:API)
haddowg/json-api-symfony    (framework integrations — each emits the same
haddowg/json-api-laravel     OpenAPI 3.1 document + JSON Schemas; the core
  …or the core directly      serves them from any PSR-15 stack)
haddowg/json-api-ts         (this repo — consumes the contract, generates the TS client)

That spec carries everything the runtime needs: machine-readable type identity, relationship cardinality and related types, the allowed ?include paths, the per-type client-id policy, and paginator kinds. See concepts for the mental model and architecture for how the pieces fit together.

Packages

Three published packages, plus a worked example workspace.

Package Role
@haddowg/json-api-client Generic, framework-agnostic runtime, parameterised by a generated descriptor.
@haddowg/json-api-codegen CLI: OpenAPI (+ JSON Schemas) → descriptor + types + a bound createClient.
@haddowg/json-api-query TanStack Query option/key factories + type:id cache normalisation.

The example workspace is the source of truth for API shape

packages/example is a worked, tested usage reference: every snippet in example.test.ts is a real, typed call against the generated client, run under pnpm test so it can't rot. When in doubt about a signature, read it there.

A 30-second taste

1. Generate a client from your API's OpenAPI document

# Install the codegen (dev-only — the generated output has no runtime tie to it).
npm install -D @haddowg/json-api-codegen
# Install the runtime (and the TanStack bindings, if you want them).
npm install @haddowg/json-api-client @haddowg/json-api-query

# Read the served OpenAPI document, emit the typed client, and (optionally) the JSON Schema map.
npx json-api-codegen \
  --input https://music.example/docs.json \
  --output src/api/music.gen.ts \
  --schemas https://music.example/schemas.json

No running API yet?

Point --input at the committed music-catalog spec this repo's own clients are generated from and conformance-tested against — the command is immediately runnable:

npx json-api-codegen \
  --input https://raw.githubusercontent.com/haddowg/json-api-ts/main/packages/codegen/test/fixtures/music-catalog.openapi.json \
  --output src/api/music.gen.ts

The generated src/api/music.gen.ts is committed into your repo — reviewable, diffable, versioned, à la openapi-typescript. It imports @haddowg/json-api-client at runtime; regenerate it when the API changes. See codegen for the full CLI.

2. Create a client

import { createClient } from './api/music.gen'

// The generated `createClient` bakes in the descriptor and the server's atomic capability,
// so you supply only options. `transport` defaults to the global `fetch` when omitted.
const client = createClient({
  baseUrl: 'https://music.example',
  headers: () => ({ Authorization: `Bearer ${getToken()}` }), // optional, may be async
})

3. One typed read

// List with a typed filter, sort, an `include` that hydrates `artist`, and a sparse fieldset.
const albums = await client.albums.list({
  filter: { title: 'OK' },
  sort: '-releasedAt',
  include: ['artist'],
  fields: { albums: ['title', 'status', 'artist'] },
  page: { number: 1 },
})

albums[0]!.title // typed string
albums[0]!.artist?.name // hydrated — `artist` is a full resource, not an identifier
albums.$page.kind // 'page' — pagination rides the array (count-free-safe)

include widens artist into a hydrated resource; fields[albums] narrows the result to exactly the listed members — any other attribute is statically absent from the type. More in reading.

4. One typed write

// Flat input — the client builds the JSON:API envelope and materialises the 201 response.
const created = await client.albums.create({ title: 'Kid A', status: 'released' })
created.id // the server-assigned id

await client.albums.id('1').update({ title: 'OK Computer (Remaster)' })
await client.albums.id('1').delete()

You pass flat attributes; the client wraps them into the JSON:API document and flattens the response back out. See writes.

Where to next

  • Getting started — install, generate against your own API, and make your first call.
  • Concepts — resource objects, materialisation, hydration, and the augmented arrays that carry the envelope.
  • Reading — list, get, follow relationships, filter, sort, include, and paginate.
  • TanStack Query bindings — option/key factories and type:id write-through normalisation for React.

For the full worked application, see the example app — a React + TanStack Query Spotify clone over a mock transport.