Concepts¶
The mental model behind json-api-ts — why it is generated the way it is, and why a
read comes back as a flat, hydrated object. Read this once and the rest of the docs
will feel obvious.
There are only two ideas to hold:
- The OpenAPI document is the contract. Everything the client needs to be typesafe is already in the spec your API serves.
- The wire envelope is materialised. The nested JSON:API document a read returns is
turned into flat resource objects with hydrated relations, so you work with
album.titleandalbum.artist.name, notdata.attributes.title.
Everything else is machinery in service of those two ideas.
The OpenAPI document is the contract¶
JSON:API's wire shape is invariant — every response is { data, included?, meta?, links? },
every resource is { type, id, attributes?, relationships? }. That regularity is the whole
point: the shape never changes, only the catalogue of types does. And the catalogue is
fully described by the OpenAPI 3.1 document your haddowg/json-api-powered
backend — the Symfony bundle, the Laravel package, or the core in your own stack — emits at /docs.json.
The spec is self-describing for everything the client needs — no bespoke extension is required to read it:
- Type identity is machine-readable — every
<Type>Resource/<Type>ResourceIdentifierschema carriesproperties.type.const(e.g.const: "albums"). - Relationships are derivable from structure: enumerate a resource's
relationships.properties; the relationship component'sdatagives cardinality (an array = to-many;anyOf [ident, null]= to-one) and the related type (follow the$refto the identifier, read itstype.const). A nestedanyOf= polymorphic (more than one related type). - The allowed
?includepaths are handed over literally, pre-expanded as a bounded enum per endpoint (artist,artist.albums,tracks.album, …) — so include-driven typing is a union over a finite set, never open recursion. - The create client-id policy is encoded in the create request body:
id: false(forbidden),idinrequired, oridoptional. - Paginator kind, sortable/filterable keys, countable tokens, and custom actions are all read off the same document.
One typed client per server
The spec is per-server. The music-catalog example ships a default and an admin server
that differ in type-set and path prefix, so you generate one client per server. See
codegen.
The codegen emits a descriptor, not per-endpoint code¶
Because the wire shape is invariant, all the real work — serialise, deserialise, resolve includes, materialise, normalise — is generic machinery that only needs to know the type catalogue. So the codegen does not template a client method per OpenAPI operation (the usual OpenAPI-generator approach, which produces large, brittle output that fights JSON:API's regularity).
Instead it emits a single runtime descriptor — a plain as const object describing
each type — and derives the TypeScript types from it (ADR 0001).
The generated file looks like this (abridged):
export const resourceMap = {
albums: {
attributes: { title: 'string', status: 'string', releasedAt: 'date-time', /* … */ },
relations: {
artist: { cardinality: 'one', types: ['artists'], mutations: { set: true } },
tracks: { cardinality: 'many', types: ['tracks'], mutations: { add: true, remove: true, replace: true } },
},
paths: { fetchOne: '/albums/{id}', fetchMany: '/albums', create: '/albums', /* … */ },
paginator: 'page',
clientId: 'forbidden',
includable: ['artist', 'artist.albums', 'tracks', 'tracks.album', 'tracks.playlists'],
// sortable, filterable, countable, actions …
},
// artists, tracks, playlists, …
} as const satisfies ApiDescriptor
export type ResourceMap = typeof resourceMap
export const createClient = (options: ClientOptions) => /* binds resourceMap into the runtime */
The descriptor carries exactly what types alone can't express at runtime — attribute-vs-relation,
cardinality, related type(s), per-operation path templates (respecting uriType and server
prefixes), paginator kind, and the client-id policy — plus generated interfaces for the precise
attribute types. This is one source of truth: the value drives the runtime, the types are computed
from it.
The payoff: tiny, uniform generated output and a single generic runtime that works for any API without change. The trade-off, recorded in the ADR, is that the descriptor shape is the contract between the codegen and the runtime, so changing it is a breaking change across both packages.
The descriptor is fully static
resourceMap is a plain object literal — you can read it, log it, and TypeScript sees it
as const, so resourceMap.albums.relations.artist.types narrows to ['artists']. It has
no runtime tie to the codegen; the generated file only imports @haddowg/json-api-client.
The generic runtime is parameterised by the descriptor¶
@haddowg/json-api-client ships the generic machinery: the fetch-shaped transport seam,
content negotiation, (de)serialisation, materialisation, the fluent read/write surface, typed
errors. It knows nothing about albums or tracks — it is parameterised by the descriptor
the codegen bakes in:
import { createClient } from './api/music.gen'
const client = createClient({ baseUrl: 'https://music.example' })
// The descriptor tells the runtime that `albums` exists, that `artist` is a to-one to
// `artists`, and that `/albums/{id}` is the fetch path. There is no per-endpoint code.
const album = await client.albums.id('1').get({ include: ['artist'] })
The fluent surface (client.<type>.list/get/create, .id(id).update/delete, relationship
accessors, .actions.<name>, client.atomic) is all generic — it reads the descriptor to
know which types, relations, and verbs exist and gates the surface accordingly (an unadvertised
relationship verb is typed never, a suppressed related endpoint is a compile error). The
architecture page details how the packages fit together.
Reads are materialised into flat resource objects¶
A JSON:API read returns a normalised wire document — primary data plus a flat included
array, with relationships expressed as { type, id } identifiers you'd have to stitch back
together yourself. The client does that stitching for you and materialises the envelope
into a self-contained hydrated graph (ADR 0002):
const album = await client.albums.id('1').get({ include: ['artist', 'tracks'] })
album.title // attribute — a plain string
album.artist?.name // hydrated — `artist` is a full resource object (you asked to include it)
album.tracks // an augmented array of Track resource objects
album.tracks.length // it's a real array
Two design choices make this ergonomic and safe:
- Data is flattened as own enumerable properties —
type,id, attributes, and relations are plain props, so{ ...album }andJSON.stringify(album)are clean. The wire envelope (resource meta, links, the shared top-level document) is exposed through non-enumerable$-prefixed accessors ($meta,$links,$self,$document,$edge,$pivot,$rel,$raw).$is collision-proof because JSON:API forbids it in member names;type/idstay plain because the spec reserves them. - The return type is computed from the
includeargument. Because the spec hands over a bounded enum of includable paths, TypeScript can promise that an included relation is a full resource and a non-included one isIdentifier | undefined. Ask forinclude: ['artist']andalbum.artistis anArtist; omit it and it's an identifier (orundefined).
Missing includes are graceful, never an error
If the server omits a relation you didn't include, it stays an identifier (with a dev-mode warning) — the client never throws at the materialisation boundary. See materialisation.
Going deeper: per-edge views and identity by type:id
A related value is a per-edge view — it reads through to the underlying resource for its
attributes but carries its own edge-local envelope ($edge, and $pivot for a
belongsToMany relation). The same track reached through two different playlists is two
distinct views with different pivot data, so identity is by type:id, never object
reference — playlist.tracks[0] is not reference-equal to the same track fetched
standalone. This is deliberate: edge-local data ($pivot/$edge) must never leak onto the
shared node. Materialisation covers the accessors in full.
Generated code is committed into your repo¶
The codegen writes the descriptor + types + bound createClient into a file in your
repository (one file, or a small directory) — à la openapi-typescript or graphql-codegen.
It is reviewable, diffable, and versioned. It is not a published package: it imports
@haddowg/json-api-client at runtime, and you regenerate it whenever the API changes and
commit the diff.
This keeps the surprise out of your build: the types your editor sees are the types in your git history, and a spec change shows up as a reviewable diff rather than a silent behaviour shift. See codegen for the CLI and config.
Where to go next¶
- architecture — the three packages, the transport seam, and how a request flows through the runtime.
- codegen — pointing the CLI at your spec and what it emits.
- materialisation — the resource-object shape,
$-accessors, hydration, and augmented arrays in detail.
For the full design rationale and glossary, read CONTEXT.md and the
ADRs.
See it in the example app¶
- The generated descriptor and types for a real API:
music-catalog.gen.ts — the
resourceMapand derivedResourceMaptype this page describes. - Every concept exercised as a tested, typed call:
example.test.ts (run under
pnpm test, grouped bydescribe(...)). - A full worked React + TanStack Query app over the same generated client: spotify-clone.
Next: architecture · codegen · reads