Generating the client¶
The typed client is generated, not hand-written. @haddowg/json-api-codegen
reads the OpenAPI 3.1 document your haddowg/json-api-powered API serves and
emits a single TypeScript module — a runtime descriptor, per-type attribute
types, and a descriptor-bound createClient. This page walks the CLI from the
one-line invocation through to wiring the drift gate into CI.
The one command¶
Point --input at your served (or exported) OpenAPI document and --output at
a file in your repo:
npm install -D @haddowg/json-api-codegen
json-api-codegen \
--input https://music.example/docs.json \
--output src/api/music.gen.ts
That writes one file — src/api/music.gen.ts — which you commit like any other
source. The getting started walkthrough uses exactly this.
To try it without a running API, point --input at the committed music-catalog
spec this repo's own clients are generated from and conformance-tested against:
https://raw.githubusercontent.com/haddowg/json-api-ts/main/packages/codegen/test/fixtures/music-catalog.openapi.json.
Two bin names
The package installs both json-api-codegen and the short alias japi, so
japi --input … --output … is equivalent. The generated output has no
runtime tie back to the codegen — it imports only @haddowg/json-api-client
— so the CLI stays a dev-only dependency.
Inputs¶
--input is the source spec; --output is where the client lands. Both inputs
accept either a URL or a local file path, and the parser reads JSON or
YAML from either.
| Flag | Required | What it is |
|---|---|---|
--input |
yes | OpenAPI 3.1 document — an http(s) URL or a local file (.json/.yaml). |
--output |
yes | Output file for the generated client, e.g. src/api/music.gen.ts. |
--schemas |
no | JSON Schema source (URL or path) for the validation seam. |
--examples |
no | Also write the operations' worked examples to a sibling module. See below. |
--server |
no | Target server name. Metadata-only — the served document is already per-server. |
--check |
no | Drift gate — regenerate in memory and fail if the committed output is stale. |
A local spec is convenient for CI (see below); a URL is convenient during development against a running API. Either resolves to the same generated output for the same spec content.
The CLI prints a wrote <path> line for each file it writes. Warnings go to
stderr with a [json-api-codegen] prefix, such as a newer contract than it
reads, a relation that collides with a verb, a paginator it can't read, or a
document with no JSON:API resource types at all, which generates an empty client.
The document's info.x-generator.contract is a compatibility number the projector
stamps, which moves only when the document's structure does. This codegen reads
contracts 1 to 22. A document with no contract, from a server before
haddowg/json-api 1.1.0, is read as it is, and a newer one generates with a warning
that the client may miss what the server added. What each server release added is in
the haddowg/json-api changelog.
A few things the codegen reads from the document that affect the generated client:
- Links are typed loosely: the client reads
hreffrom a link object and accepts a plain string too. - Relation paths use the relation's URI field name, which can differ from its name
(
/articles/{id}/published-byforpublisher). The codegen finds each relation's endpoints by operationId (fetchRelated.articles.publisher) and records the segment as the relation'suriField, which the client puts in the path. - Wrapped
$refs. A$refthat carries anexample,examplesordefaultis written as a single-entryallOf. The codegen reads it as the$refit holds, so an enum attribute is still typedstatus: ArticleStatus, and the wrapper's description, example and default become TSDoc on the member. - Raw media types. An action that answers in media types other than JSON:API gets
each one recorded as
rawOutput, read asbinaryfor aformat: binaryschema,textfor any other string schema andjsonotherwise. A JSON schema is typed under its component's name (ArticleSummary) or, when inline, under<Type><Action><MediaType>Output. An action that accepts several request bodies gets them asinputMedia, read the same way, and a multipart entry becomes aFormDatabody. See raw responses and request media types. - Action docs. An action's summary and description become the JSDoc of its method.
The projector's stand-in text for an action its author didn't describe ("Invoke the
reportaction") is left out. When the document carries worked examples, the method shows one as an@example: the request body when the action takes one, otherwise its first success response, pretty-printed under a title such as "Request body" or "Response200astext/csv". A body longer than 80 lines is named but not shown; the examples module still has it. The CRUD and relationship methods come from the generic runtime, so their examples are only in the examples module. - Several methods. Each method of a multi-method action gets its own action method,
as described under several methods.
An action's
x-safetyflags go into the descriptor asreadOnly,destructiveandidempotent, and its JSDoc says which ones it declares. An action's own vendor extensions, such asx-workflows, are left out. - OAuth scopes an operation lists in its
securityunder the server'soauth2scheme (found bytype, whatever it's named) go into the descriptor: per type inscopes, keyed by operation, on each action asscopes, and on theatomicconst. The JSDoc of the descriptor'spathsentries, relations, actions andatomicnames them too, so a hover onresourceMap.articles.relations.authorlists what each of its endpoints needs. - Polymorphic relations. A relation whose linkage is a union is marked
polymorphic: truewhatever its member count, so aMorphToover a single type is polymorphic too. Its related and relationship endpoints don't advertise the related type'sfilter,sortandpageparameters. - Location documents. An action that answers with a URL, such as a short-lived
download link, is marked
location: { mediaType }and, when the URL is all it answers with,output: "location". The client then returns the URL instead of fetching it, as described under actions that answer with a URL. An action that only documents a303has nolocation, and the transport follows the redirect.
The validation schemas¶
Add --schemas to also read the server's JSON Schema bundle and emit a sibling
module carrying a per-type schema map — the fuel for the client's opt-in
validation seam:
json-api-codegen \
--input https://music.example/docs.json \
--output src/api/music.gen.ts \
--schemas https://music.example/schemas.json
The schema artifact path is derived from --output: a .gen.ts suffix
becomes .schemas.gen.ts (so the example above also writes
src/api/music.schemas.gen.ts), a plain .ts becomes .schemas.ts. Omit
--schemas and no schema module is written — validation is entirely optional.
Operation examples¶
Add --examples to also write the worked examples the document declares
to a sibling module, named the way the schema module is: music.gen.ts gets
music.examples.gen.ts. It exports examples, typed as the client's
ApiExamples, for a consumer that wants each operation's sample without reading
the OpenAPI document, such as a tool that describes every operation:
import { examples } from './api/music.examples.gen'
examples.albums?.operations?.create?.request?.['application/vnd.api+json']
examples.albums?.operations?.['fetchRelated.tracks']?.responses?.['200']
examples.albums?.actions?.reissue?.request
Each type has operations, keyed by the operation's operationId without the
type (fetchCollection, fetchOne, create, update, delete, and
fetchRelated.<relation>, fetchRelationship.<relation>,
updateRelationship.<relation>, addRelationship.<relation>,
removeRelationship.<relation>), and actions, keyed by action name. Each entry
has a request and responses keyed by status, and each of those maps a media
type to its example. A body is the whole wire document, not the flat input or
materialised result the client works with. Error responses are shared components
in the document rather than per operation, so they are left out.
Anything without an example is left out: a type, an operation, a request or a status. A document with no examples writes no module and warns that it has none. The module is off by default because it is several times the size of the client and an app that only calls the API has no use for it.
Multiple servers¶
The backend serves one document per server (a default and an admin server
typically differ in their type set and path prefix). Generate one client per
server by pointing --input at each server's document, writing each to its own
--output. The --server flag is metadata-only — it does not slice a combined
document; you select the server by choosing the right input URL.
What it generates¶
The codegen only reads the spec — the spec is fully self-describing for the
runtime's needs, and all the real machinery lives in the generic
@haddowg/json-api-client. The emitted module exports:
resourceMap— the runtimeApiDescriptor, emittedas const satisfies ApiDescriptor. This is the descriptor the generic runtime is parameterised by.ResourceMap—typeof resourceMap.createClient(options)— the descriptor-bound factory. The descriptor and the server'satomiccapability are baked in; you supply onlyClientOptions(baseUrl,transport?,headers?, …).Attributes/WriteAttributes— the per-type attribute maps (read vs create/update), plus the per-type…Attributes/…CreateAttributes/…UpdateAttributesinterfaces, enum unions (e.g.AlbumStatus), and per-action input/output aliases.Links— each type that declares links, mapped to its…Linksinterface. See below.ErrorCode/ErrorCodeByStatus/hasErrorCode— the server's documented error codes and a type guard bound to them. See below.pageLimits/relatedPageLimits— the page defaults and caps of each collection and each related collection. See below.pageKinds/relatedPageKinds— the pagination strategies each collection and related collection offers. See below.
With --schemas, the sibling module exports schemas — the per-type JSON Schema
2020-12 map. With --examples, another exports examples, each operation's
worked examples.
import { createClient, type ResourceMap, resourceMap } from './api/music.gen'
import { schemas } from './api/music.schemas.gen'
const client = createClient({ baseUrl: 'https://music.example' })
Attribute types¶
Each attribute interface is generated from its own schema: …Attributes from the
response schema, …CreateAttributes and …UpdateAttributes from the request
bodies. The server can state different rules for each, so the three can differ.
A read attribute is | null exactly when the response schema admits null. That is any attribute the server doesn't guarantee a value
for, such as an optional field a create left out. Required fields, and fields the
server marks as always filled, stay non-null:
export interface TracksAttributes {
/** @minLength 1 */
title: string
/** @minimum 1 */
trackNumber: number
/**
* @minItems 1
* @uniqueItems
*/
genres: [string, ...string[]]
previewOffset: string | null
}
A nullable enum (anyOf of the enum and null) becomes AlbumStatus | null.
Values pass through as the wire carries them: a date or date-time is an ISO string
and a decimal a number, with no coercion, so a result serialises and caches unchanged.
An enum alias takes its component's name. When that name isn't a usable TypeScript type
name (it has spaces or punctuation, starts with a digit, is a reserved word, or clashes
with another name in the module), the codegen derives one from it, such as ReleaseState
for release state, and prints a warning naming both.
A nested object follows the same rules member by member. A
response schema lists every member the server always renders in the object's
required, so on reads those members lose their ? and only nullability is left
to decide. Write interfaces keep the request body's own required:
// read
packaging: { material: string; gatefold: boolean | null } | null
// create
packaging?: { material: string; gatefold?: boolean } | null
A discriminated union (a oneOf of objects with a discriminator) becomes a union of
its branches, and each branch's discriminator member is the literal its const names, so
checking it narrows to that branch. A list attribute types its elements from its items
schema, so a list of objects or of a discriminated union keeps the members, required
and nullability of each element, with read and write elements following their own
schemas as above:
// read
steps: ({ operation: "trim" } | { params: { find: string; replace: string } | null; operation: "find_replace" })[] | null
// create and update
steps?: ({ operation: "trim" } | { params?: { find?: string; replace?: string }; operation: "find_replace" })[]
A write replaces the whole list, so an update element requires what a create element does.
Every constraint the schema declares becomes a TSDoc tag on the member, so it shows
on hover: @format, @pattern, @minLength/@maxLength,
@minimum/@maximum, @exclusiveMinimum/@exclusiveMaximum, @multipleOf,
@minItems/@maxItems, @uniqueItems, @minProperties/@maxProperties, plus
@defaultValue, @example and @deprecated. The tags document the rule; the
client doesn't enforce them (use validation for that). One
constraint changes the type: an array with minItems of 1 or more is a non-empty
tuple, [T, ...T[]], on reads and writes alike, so a create can't send an empty
list the server would reject.
Write-only relations¶
A write-only relation is one the create and update bodies accept but no response
renders. The codegen marks it writeOnly: true in the descriptor. It's found by the
writeOnly: true keyword on its body property or, in an older
document, by being in the write bodies and missing from the response schema.
The client keeps it settable on create, update and atomic operations, and its
relationship accessor keeps the mutation verbs the endpoint offers. It has no slot on
the read view, isn't a fields member, and its accessor has no .get() or
.related():
await client.articles.create({ title: 'Draft', reviewer: { type: 'people', id: '3' } })
await client.articles.id('1').reviewer.set({ type: 'people', id: '4' })
const article = await client.articles.get('1')
article.reviewer // type error: not on the read view
Pivot fields¶
A belongsToMany relation describes its meta.pivot twice in the descriptor:
readPivot for what a response renders and writePivot for what a request sends.
Each holds the field hints, the fields that are always there (required, absent
when every field is) and the fields that may be null:
orderedTracks: {
cardinality: "many", types: ["tracks"], pivot: true,
readPivot: {
fields: { addedAt: "date-time", position: "integer", weight: "integer" },
nullable: ["weight"],
},
writePivot: {
fields: { position: "integer", weight: "integer" },
required: ["position"],
},
// …
}
They come from the <Type><Rel>Pivot and <Type><Rel>WritePivot
components, so a read-only field such as addedAt is on reads and never asked for
on writes. That types $pivot like this:
// read: a member of .related(), .get() or an included relation
$pivot?: { readonly addedAt: string; readonly position: number; readonly weight: number | null }
// write: .add(), .replace(), create and update
$pivot?: { readonly position: number; readonly weight?: number }
An older document has one shared pivot schema. The codegen treats every field as
rendered on reads, and drops the fields marked readOnly from writes.
Declared links¶
A server can declare named links on a type, such as a signed upload URL with the
method, headers and expiry to use. The document lists each one under the type's
<Type>ResourceLinks, which extends the shared
ResourceLinks through an allOf, and the codegen emits a …Links interface for every
type that declares any:
export interface ImagesLinks extends ResourceLinks {
/** Where to fetch the original file. */
download?: {
href: string
title: "Original"
type: "image/png"
meta: {
/** The HTTP method to use. */
method: "GET"
/** When the URL stops working. */
expiresAt: string
headers?: Record<string, unknown> | null
}
}
/** The `thumbnail` link, absent when it does not apply to the resource. */
thumbnail?: string
}
export interface Links {
images: ImagesLinks
}
Every declared link is optional, because the server leaves a link out when it doesn't
apply. A link with meta or static members is a link object: href, each static member
as its literal, and meta typed member by member the way attributes are, with each
member's description and example as TSDoc. meta is optional unless one of its members
is always rendered, and a member that can be hidden is optional inside it. A link with
neither is a bare URL string. Meta values pass through as the wire sends them, so a
date-time member is an ISO string.
createClient passes Links to the runtime, so $links on a resource of that type is
typed without a cast:
const image = await client.images.get('img-1')
image.$links?.download?.href // string
image.$links?.download?.meta.expiresAt // string
image.$links?.thumbnail // string | undefined
image.$links?.['other'] // unknown: undeclared links stay reachable
self and any link the type doesn't declare keep the loose ResourceLinks types, and
a type that declares no links isn't in Links and keeps ResourceLinks as it was. If
you spell out a result type by hand, pass WithLinks<Attributes, Links> where you would
pass Attributes, or $links falls back to ResourceLinks.
Page defaults and caps¶
Each page member states its default and the page-size
cap as a maximum. The codegen collects them per collection, keyed by wire key and,
for a strategy menu, by arm:
export const pageLimits = {
albums: {
number: { default: 1, minimum: 1 },
size: { default: 15, minimum: 1, maximum: 50 },
},
tracks: {
page: { number: { default: 1, minimum: 1 }, size: { default: 15, minimum: 1, maximum: 50 } },
cursor: { size: { default: 15, minimum: 1, maximum: 50 } },
offset: { offset: { default: 0, minimum: 0 }, limit: { default: 15, minimum: 1, maximum: 50 } },
},
} as const
The client sends the page you pass as it is and never clamps it. Read
pageLimits when you need the numbers, for example to bound a page-size picker. A
collection whose page schema states none of them is left out.
A related collection, such as GET /albums/{id}/tracks, can page differently from the
related type's own collection. relatedPageLimits reads each one from its related
endpoint and keys it by parent type, then by relation name. Below that it has the same
shape as pageLimits:
export const relatedPageLimits = {
albums: {
tracks: {
number: { default: 1, minimum: 1 },
size: { default: 2, minimum: 1, maximum: 100 },
},
},
} as const
A relation whose related endpoint states no bounds is left out, and so is a parent left
with none. When no related collection states any, it is {}.
Pagination strategies¶
pageKinds lists the strategies each paginated collection offers, keyed by type, and
relatedPageKinds does the same for each related collection, keyed by parent type, then
by relation name. A single strategy is a one-entry tuple. A
strategy menu lists the kind of
each arm, and the one the server uses when the request names none comes first:
export const pageKinds = {
albums: ['page'],
tracks: ['page', 'cursor', 'offset'],
} as const
export const relatedPageKinds = {
albums: { tracks: ['page'] },
} as const
Every paginated collection is listed, whether or not its members state bounds. The
codegen reads the strategy from the x-paginator stamp on the page schema, which names
a single strategy or, for a menu, its default. Every built-in haddowg/json-api
paginator stamps it. A collection whose page schema has no stamp, as a custom paginator
may leave it, is left out of both consts, as is a menu whose stamped default isn't one
of its arms.
Error codes¶
A haddowg/json-api document catalogues the error codes the server can send. Each
components.schemas.ErrorDocument<NNN> offers its codes as anyOf branches, one
<Code>Error component per code with a code const, plus the open generic Error.
The codegen reads them into four exports:
export const ErrorCodes = {
/** The credential is valid but lacks permission. */
ACCESS_DENIED: "ACCESS_DENIED",
RESOURCE_NOT_FOUND: "RESOURCE_NOT_FOUND",
// …
} as const
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
export interface ErrorCodeByStatus {
403: "ACCESS_DENIED"
404: "RESOURCE_NOT_FOUND"
500: never
}
export const hasErrorCode = hasErrorCodeRuntime as ErrorCodeGuard<ErrorCode>
ErrorCodeshas a key for every code any error document lists, sorted, each holding its own code. A key gets TSDoc when its component has adescription, plus an@examplefor eachexample/examplesvalue on the component'sdetailproperty. An older document has neither, so its keys carry no TSDoc. A code that isn't a valid identifier gets a quoted key.ErrorCodeis the union of those codes, derived fromErrorCodes. The TSDoc lives on the object because editors show it on hover and in completion for an object key, but not for a member of a string literal union.ErrorCodeByStatusmaps each status that has anErrorDocument<NNN>to its codes. A status whose document offers only the genericErrormaps tonever.hasErrorCodeonly accepts anErrorCode, so a misspelt code fails to compile. See errors for using it.
The bound createClient passes ErrorCode to the runtime, so the client type
carries it too. A document with no error catalogue gets an empty ErrorCodes and
ErrorCode = string, and the guard then takes any code.
Deprecation¶
The server can mark a type, an operation, an action, an attribute, a relation, a filter, a sort key or a sort family deprecated. A deprecated thing keeps working, so the client still types and sends it. The codegen turns each marker into TSDoc, and where the client surface reaches the tagged entry, your editor strikes the name through:
- An attribute marked
deprecatedgets@deprecatedon its member in…Attributes,…CreateAttributesand…UpdateAttributes, at any depth. The read view's attribute is struck through. - A relation marked deprecated in the read resource or a write body gets
@deprecatedon its entry inresourceMap, soclient.articles.id('1').formerAuthoris struck through. - A deprecated action gets
@deprecatedon itsactionsentry, soclient.articles.id('1').actions.retractis struck through. - A type whose every operation is deprecated gets
@deprecatedon itsresourceMapentry, soclient.tagsis struck through. Each deprecated CRUD operation also gets one on itspathsentry, and a relation whose every endpoint is deprecated gets one on its entry.
When the server gives a sunset date, the tag names it:
Some markers have nowhere to strike through. A single deprecated endpoint of a relation, such
as its related read, is named in the relation's TSDoc instead, because the relation itself
still works. The sentence reads "The fetchRelated endpoint is deprecated." The get, list, create, update
and delete methods are the same for every type, so they carry no per-type tag either; the
type's paths entry does. Filter keys, sort keys and sort families are members of
filterable, sortable and sortFamilies, and a member of a string literal union can't
carry TSDoc. The codegen names the deprecated ones in the TSDoc on those three entries
instead:
A document with no markers generates no deprecation TSDoc at all.
Inside the descriptor¶
Each wire type in resourceMap carries a compact ResourceDescriptor. Here is
the albums entry from the example client,
lightly trimmed:
export const resourceMap = {
albums: {
attributes: { title: "string", releasedAt: "date-time", status: "string", /* … */ },
relations: {
artist: { cardinality: "one", types: ["artists"], pivot: false, mutations: { set: true } },
tracks: {
cardinality: "many", types: ["tracks"], pivot: false,
mutations: { add: true, remove: true, replace: true },
countable: { tokens: ["_self_", "playlists"], profile: "…/countable/" },
},
},
paths: {
fetchMany: "/albums", fetchOne: "/albums/{id}", create: "/albums",
update: "/albums/{id}", delete: "/albums/{id}",
fetchRelated: "/albums/{id}/{rel}", fetchRelationship: "/albums/{id}/relationships/{rel}",
},
paginator: "page",
clientId: "forbidden",
includable: ["artist", "artist.albums", "tracks", "tracks.album", "tracks.playlists"],
sortable: ["title", "-title", "releasedAt", "-releasedAt", "status", "-status"],
filterable: ["artist.name", "q", "rating", /* … */],
},
// …
} as const satisfies ApiDescriptor
Every field is derived from the spec. Where the server stamps a vendor extension
(x-profile, x-paginator) the codegen reads it; otherwise it works from the
document's structure:
attributes— the read attribute names with a coarse wire-format hint (string/number/boolean/date/date-time/object). The precise per-type interface (e.g.AlbumsAttributes) is emitted alongside and wired onto the client through theAttributesmap.relations— per relation:cardinality(one/many), the relatedtypesarray, whether it is apivot(belongsToMany) relation (with itsreadPivotandwritePivot), whether it iswriteOnly, itsuriFieldwhen its paths use a segment other than its name,polymorphic: truewhen its linkage is a union of types, even of one (readisPolymorphic(relation), which falls back to countingtypesin an older descriptor), and the derivedmutationscapability (add/remove/replacefor a to-many,setfor a to-one — read from the relationship endpoint's HTTP methods). A relation whose related or relationship endpoint is suppressed by the API is markedrelated: false/relationship: falseso the client gates it off rather than offering a call that 404s. A relation may also carry its owncountableand a divergentpaginator.paths— only the per-operation paths that actually exist (fetchMany/fetchOne/create/update/delete/fetchRelated/fetchRelationship). A read-only type simply has nocreate/update/delete.-
paginator— how the collection paginates, read from itspagequery parameter:- When the page schema carries
x-paginator, the stamp decides the strategy (page,offset,cursororfixed) and the wire key for each of its roles. With the default keys the descriptor holds the bare kind ("page"); with renamed keys it holds{ strategy: "page", keys: { number: "p", size: "size" } }. - A
MultiPaginatorstamps{ strategy: "multi", default }on aoneOfof arms. The descriptor becomes a menu,{ strategy: "multi", default: "page", arms: { page: "page", cursor: "cursor" } }, keyed by each arm'skindconst. An arm without its own stamp is inferred from its member names; an arm nothing recognises is left out with a warning. - Without a stamp (an older document, or a custom paginator that stamps
nothing) the codegen infers the strategy from canonical member names, in
either the
pageobject parameter or the flattenedpage[number]form:number+sizeispage,offset+limitisoffset,afterorbeforeiscursor,numberalone isfixed. Renamed keys can't be inferred. - Anything unrecognised, including a strategy this codegen doesn't know, logs a
warning and generates as
none.
See pagination for the typed
pageargument and$pagethis drives. -clientId— the create client-id policy:forbidden/optional/required, read from the create-request schema. See writes. -includable/sortable/filterable— the bounded, pre-expanded token sets the collection read accepts (?includepaths incl. nested dotted forms; signed sort fields;filter[…]keys). These drive typed narrowing on the read surface — see includes and sparse fieldsets and filtering and sorting. -sortFamilies: the sort families the collection read accepts besidesortable, read from thesortparameter'sx-sort-families. Each is aprefixand thesuffixPatternits keys' suffix must match, and adds<prefix>.${string}and-<prefix>.${string}to the type's sort union. When the parameter has families its items are ananyOfof the fixed-key enum and one pattern per family, andsortableholds that enum's keys. Absent when the type declares no family. See sort families. -included— the types a compound document of the type can carry, read from the document's typedincluded(aoneOfover<Type>Resourcecomponents discriminated bytype). A relation'sincludedlists the same for its related read. Absent when nothing is includable, or when the document typesincludedonly as{type, id}. The generatedIncluded<'albums'>andRelatedIncluded<'albums', 'tracks'>types are the matching resource unions, narrowed bytype; without a list they cover every type. -countable— the collection'swithCounttokens and the negotiation profile URI (read from the parameter'sx-profile, never hardcoded). -actions— custom actions keyed by name, each carryingscope(resource/collection),path,input(document/none/raw) andoutput(document/meta/location/none), plus aninputType/outputTypewhere the action names a resource, and alocationwhen it answers with a URL. Adocumentaction gets emitted…Input/…Outputtype aliases. See custom actions. An action needing OAuth scopes carries them asscopes. -scopes— the OAuth scopes each operation needs, keyed by the operation id without its type (fetchOne,fetchRelated.author,addRelationship.tags). Absent when no operation of the type needs one. - When the page schema carries
The server-level Atomic Operations capability is emitted separately as an
atomic const and threaded into createClient by default — see
atomic operations.
Going deeper: verb/relation collisions
If a type has a relation named like a reserved verb on the resource handle
(get, update, delete, create, list, actions, rel, …), the
codegen emits a build-time warning and that relation routes through
.rel('name') in the fluent client instead of a bare accessor. The common
case — relations that do not shadow a verb — stays clean. The warning names
the type and relation so you know which one moved.
Going deeper: the programmatic API
The same generation is callable from a script — useful for a build step or a custom regeneration harness:
import { generate, generateFiles, check, type CodegenConfig } from '@haddowg/json-api-codegen'
const config: CodegenConfig = {
input: 'https://music.example/docs.json',
output: 'src/api/music.gen.ts',
schemas: 'https://music.example/schemas.json', // optional
examples: true, // optional
server: 'default', // optional, metadata-only
}
await generate(config) // writes the file(s); returns the client source
await generateFiles(config) // the same, returning the paths it wrote
const { ok } = await check(config) // drift gate: regenerate in memory, compare, no write
Lower-level building blocks are exported too — readDocument / readSchemas,
buildDescriptor, buildExamples / operationExamples, emit / emitSchemas /
emitExamples, readErrorCatalogue / emitErrorCodes, detectVerbCollisions,
schemasOutputPath / examplesOutputPath, the provenance helpers
deriveProvenance / provenanceLines / hashJson, and checkContract with
MINIMUM_CONTRACT / MAXIMUM_CONTRACT, which throws for a document older than
the codegen reads and returns a warning for a newer one.
Provenance and drift¶
The provenance stamp¶
Every generated artifact carries a provenance record in its header — the source
spec identifier (<info.title> <info.version>), the first declared server URL,
and a stable 16-hex content hash of the source document:
/**
* AUTO-GENERATED by @haddowg/json-api-codegen — do not edit by hand.
* Regenerate from the source OpenAPI document instead.
*
* Source spec: Music Catalog API 1.0.0
* Server: https://music.example
* Spec hash: c73c5d49624ebbe8
*/
The stamp is timestamp-free and deterministic — the hash is taken over the canonicalised document (object keys sorted), so two byte-different-but-equivalent specs hash the same, and regenerating from the same spec yields byte-identical output. That determinism is what makes the header a reliable review anchor (which spec produced this committed client?) and what lets the drift gate compare output byte-for-byte.
Committing and regenerating¶
Commit the generated files. Like openapi-typescript or graphql-codegen,
the output is one small, reviewable, diffable, versioned module in your repo — not
a published package. Regenerate whenever the API changes and commit the diff; a PR
then shows exactly how the wire contract moved.
Drift checking (--check)¶
--check regenerates every artifact in memory and compares it against the
committed --output (and its schema and examples siblings when --schemas and
--examples are set) without
writing anything. It prints a per-artifact ok/DRIFT line and exits non-zero
if any committed file is missing or out of date:
json-api-codegen \
--input spec/music-catalog.openapi.json \
--output src/api/music.gen.ts \
--schemas spec/music-catalog.schemas.json \
--check
Pin --input to a committed spec fixture so the check is deterministic in CI:
a drifted generated client — someone edited the spec fixture but forgot to
regenerate, or hand-edited the output — fails the build with a clear message
telling them to regenerate and commit.
Drift-checking in CI¶
This repo drift-checks all of its own generated clients from a single committed
spec fixture, and it is a good template. scripts/codegen.mjs
runs the CLI once per output, threading --check through when invoked with the
flag:
pnpm codegen # regenerate every client from the committed spec fixture
pnpm codegen:check # the CI gate — fail if any committed client is stale
Those two scripts are wired in the root package.json as
node scripts/codegen.mjs and node scripts/codegen.mjs --check. In your own
project, codegen:check belongs in the same CI job as your lint/typecheck step —
so a stale client can never merge.
One source keeps the examples honest
In this repo the same fixture generates the test snapshot in the codegen package and both example clients, so all three stay pinned to one wire contract. Your app typically has just one output per server, but the pattern is the same: one committed spec in, drift-checked clients out.
See it in the example app¶
scripts/codegen.mjs— the regenerate / drift-check harness this repo runs (pnpm codegen/pnpm codegen:check).music-catalog.client.gen.ts— the committed snapshot of a full generated client, header, interfaces, descriptor and factory.music-catalog.gen.ts— the generated client the spotify-clone app actually imports and uses.
Next¶
- Concepts — the descriptor and how the generic runtime consumes it.
- Reads — the typed read surface the descriptor unlocks.
- Validation — wiring the
--schemasoutput into the opt-in seam.