effect-jsonapi
Type-safe, spec-compliant JSON:API v1.1 on Effect's HttpApi.
Installation
npm install @thomasfosterau/effect-jsonapi effecteffect is a peer dependency (>=4.0.0-beta.84). Node.js 20 or newer is required.
Overview
effect-jsonapi makes it trivial to comply with the JSON:API spec, invariantly — compliance
is a property of the construction, not of developer discipline:
- Define each resource once; identifiers, create/update payloads, documents, query parameters and endpoints are all derived from that single definition.
- Declare each error once; you get a tagged Effect error whose wire encoding is a spec-compliant JSON:API error document with the right HTTP status.
- Endpoints bake in the conventions: the
application/vnd.api+jsonmedia type, conventional paths, spec status codes (200/201/204), typedinclude/fields[TYPE]/sort/page[*]/filter[*]query parameters, and content-negotiation rules (406/415). - Everything is a plain Effect
Schema/HttpApiEndpoint/HttpApiGroup, so it composes withHttpApi,HttpApiBuilder,HttpApiClient,HttpApiTestandOpenApiuntouched.
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { Endpoint, Group, Resource } from "@thomasfosterau/effect-jsonapi"Status: built against
effect@>=4.0.0-beta.84(the v4 beta). Theeffect/unstable/httpapisurface may shift between betas.
Contents
- Quick start
- 1. Resources — the single source of truth
- 2. Errors — declared once, spec-compliant forever
- 3. Endpoints & groups — conventions baked in
- 4. Handlers — typed in, validated out
- Query parameters
- Spec compliance, by construction
- Examples
- Metadata
- Limitations
Quick start
A complete read API — resource, error, endpoints, handlers, server — in one file. Each piece is expanded in the sections below.
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { ApiError, Endpoint, Group, Handlers, Middleware, Query, Resource } from "@thomasfosterau/effect-jsonapi"
// 1. Define a resource once — identifiers, payloads, documents and query
// parameters are all derived from this single definition.
const Article = Resource.make("articles", {
attributes: { title: Schema.NonEmptyString, body: Schema.String }
})
// 2. Declare an error once — its wire encoding *is* a JSON:API error document.
class ArticleNotFound extends ApiError.make<ArticleNotFound>()("ArticleNotFound", {
status: 404,
fields: { id: Schema.String },
detail: (e) => `Article ${e.id} not found`
}) {}
// 3. Build endpoints with the JSON:API conventions baked in.
const articles = Group.make(
Article,
Endpoint.get(Article, { include: true, errors: [ArticleNotFound] }),
Endpoint.list(Article, { page: Query.Page.Offset })
)
const Api = HttpApi.make("blog").add(articles)
// 4. Implement handlers — inputs are typed and validated, documents are checked
// for the compound-document rules. (`loadArticle` / `listArticles` are your
// own data access returning `Effect`s.)
const ArticlesLive = HttpApiBuilder.group(Api, "articles", (handlers) =>
handlers
.handle("get", ({ params }) => loadArticle(params.id).pipe(Effect.map((article) => Handlers.data(article))))
.handle("list", ({ query }) => listArticles(query).pipe(Effect.map((items) => Handlers.collection(items))))
)
// 5. Wire it up — the api won't build unless the JSON:API middleware is
// provided, so spec compliance can't be forgotten.
const ApiLive = HttpApiBuilder.layer(Api).pipe(Layer.provide(ArticlesLive), Layer.provide(Middleware.layer))1. Resources — the single source of truth
const Person = Resource.make("people", {
attributes: {
firstName: Schema.NonEmptyString,
lastName: Schema.NonEmptyString
}
})
const Tag = Resource.make("tags", {
attributes: { name: Schema.NonEmptyString }
})
const Comment = Resource.make("comments", {
attributes: { body: Schema.NonEmptyString },
relationships: {
author: Relationship.one(() => Person) // a reference, not a string — typos don't compile
}
})
const Article = Resource.make("articles", {
attributes: {
title: Schema.NonEmptyString,
body: Schema.String,
createdAt: Schema.DateFromString // ISO string on the wire, Date in your code
},
relationships: {
author: Relationship.one(() => Person), // required to-one
editor: Relationship.optional(() => Person), // nullable to-one
tags: Relationship.many(() => Tag), // bounded to-many, inlined
comments: Relationship.paginated(() => Comment) // unbounded to-many, linked
}
})Relationship kinds
Each relationship declares its cardinality and how its data travels — inline as resource identifiers, or behind a link to a paginated endpoint:
| Constructor | Cardinality | Wire shape of the relationship object | In ?include= |
In create payload |
|---|---|---|---|---|
Relationship.one |
to-one | { data: identifier } — never null |
✓ | required |
Relationship.optional |
to-one | { data: identifier | null } |
✓ | optional |
Relationship.many |
to-many | { data: identifier[] } |
✓ | optional |
Relationship.paginated |
to-many | { links: { related, self? } } — no data |
✗ | ✗ (use relationship endpoints) |
one / optional / many carry inline linkage: clients see the related
identifiers right inside the parent resource and can pull the full resources
into a compound document with ?include=.
paginated is for unbounded collections (an article's comments, a user's
repositories): the relationship object carries only a required related link
pointing at a paginated collection endpoint (see
Relationship & related endpoints).
Everything below is derived — never assembled by hand:
| Derived | What it is |
|---|---|
Article |
the resource object Schema.Struct itself (type/id/attributes/…) |
Article.Id |
branded id schema — Article.Id values can't be mixed with Person.Id |
Article.identifier |
the { type: "articles", id } resource-identifier schema |
Article.ref("1") |
a typed identifier value — handy for relationship linkage |
Article.localIdentifier |
the { type: "articles", lid } schema — identifies a resource being created (no server id yet) |
Article.lidRef("a1") |
a typed local-identifier value — the lid counterpart of ref |
Article.createPayload |
{ data: { type, lid?, attributes, relationships } } — no id; one relationships required, paginated excluded |
Article.updatePayload |
{ data: { type, id, attributes? (partial), relationships? } } — attributes are tri-state; paginated excluded |
Article.createInput / Article.updateInput |
flat "command-style" request schemas — attributes (and, for update, the id) without the JSON:API envelope |
Article.document() |
single-resource document with Article as primary data (non-null); included union derived from the non-paginated relationships |
Article.collection() |
collection document (strict array data) |
typeof Article.Type |
the decoded TypeScript type |
Documents are not limited to one resource type — see Heterogeneous endpoints for polymorphic collections.
Custom id schemas
By default a resource's id is Resource.Id(type) — a string branded by resource type. Pass your
own id schema (anything whose encoded side stays a string, so the wire is spec-compliant) to
brand ids your way — e.g. an id shared across a hierarchy of types, or one decoded to a richer value:
const PersonId = Schema.String.pipe(Schema.brand("PersonId"))
const Person = Resource.make("people", {
id: PersonId, // default = Resource.Id("people")
attributes: { name: Schema.NonEmptyString }
})
Person.Id // PersonId
// Person.identifier.Type → { type: "people"; id: PersonId }
// Person.updatePayload.Type → data.id is PersonId
// Document.DataDocument(Person).Type.data.id is PersonIdThe injected id flows through identifier, updatePayload, ref, createInput/updateInput and
the document schemas. Omit id and nothing changes — existing definitions keep the auto-branded id.
Resource.Identifier(type, id?) accepts a custom id too, for standalone identifier schemas.
Subtype ids via extend. To make a subtype's id a subtype of its base's id — a PersonId that
is an AgentId is a NodeId — pass inheritId: true to extend.
The child's id brands the base's id schema, so it accumulates the base's brand(s) and is assignable
wherever the base id is expected (transitively through a chain); the reverse is rejected. Effect's
brands are intersectional, so this is just brand accumulation:
const Account = Resource.make("accounts", { attributes: { email: Schema.NonEmptyString } })
const Manager = Resource.extend(Account, "managers", { inheritId: true })
const managerId = Manager.Id.make("1")
const asAccount: typeof Account.Id.Type = managerId // ✓ a manager id IS an account id
// @ts-expect-error an account id is not a manager id
const asManager: typeof Manager.Id.Type = Account.Id.make("2")inheritId defaults to false — by default an extended resource gets a fresh, independent brand.
Update payloads: set / unset / leave unchanged
A PATCH must distinguish three intents per attribute. updatePayload models them with
Schema.optional (= optionalKey(UndefinedOr(...))), so each attribute is genuinely tri-state:
| Wire / value | Meaning |
|---|---|
| key absent | leave unchanged |
key = null† |
unset (clear) |
| key = a value | set to that value |
† null requires a nullable attribute (Schema.NullOr(...)). In-process — and over codec transports
that preserve it, like RPC / remote functions — an explicit undefined is also accepted as "unset".
Over a JSON HTTP body, JSON can't carry undefined, so null is the wire clear signal and an absent
key means "leave unchanged".
const Person = Resource.make("people", {
attributes: { name: Schema.NonEmptyString, bio: Schema.NullOr(Schema.String) }
})
// Person.updatePayload accepts, for `bio`: a string (set), null (clear), or omit (leave unchanged).
// Its type is `{ name?: string; bio?: string | null | undefined }` under `data.attributes`.Per-attribute annotations
Stamp metadata onto an attribute with Effect's schema.annotate({ ... }), and read it back per
attribute with Resource.attributeAnnotations — handy for carrying, say, a database column name
alongside the attribute schema:
const Person = Resource.make("people", {
attributes: { bio: Schema.NullOr(Schema.String).annotate({ dbColumn: "biography" }) }
})
Resource.attributeAnnotations(Person).bio?.dbColumn // "biography"Flat (command-style) payloads
Alongside the JSON:API { data: { type, attributes } } payloads, every resource exposes flat request
schemas — the attributes alone, no envelope — for transports (RPC, remote functions) that carry a
flat shape:
Person.createInput // { name, bio } — flat create attributes
Person.updateInput // { id, name?, bio? } — id plus the same tri-state attributesNullable primary data
Document.DataDocument is a pure envelope: its data member is exactly the
schema you pass, so nullability is your compositional choice — not something the
constructor decides for you. JSON:API only permits data: null for a
single-resource request whose URL might correspond to a resource but currently
doesn't; fetch-existing / create / update always carry the resource (a missing
one is a 404, never 200 { data: null }).
Document.DataDocument(Article) // data: Article
Document.DataDocument(Schema.NullOr(Article)) // data: Article | null
Document.DataDocument(Article.nullable()) // data: Option<Article>, ⇆ null on the wireArticle.nullable() is Schema.OptionFromNullOr(Article) — the spec-clean
nullable codec (None ⇆ null). Avoid effect's structural Schema.Option
({ _tag, value }): it serialises a non-conformant body, and DataDocument
can't tell the two apart. Article.document() and Endpoint.get / create /
update use the non-null form; Endpoint.related for a to-one relationship
keeps the nullable form (data: target | null) for the empty-linkage case.
Reusing & extending resources
When several resources share a set of attributes or relationships, define them
once and reuse them. Resource.attributes / Resource.relationships extract a
resource's field map and descriptor record so you can spread them into another
definition:
const Profile = Resource.make("profiles", {
attributes: { ...Resource.attributes(Person), bio: Schema.String }
})Resource.extend does the same wholesale — a subtype that inherits the
base's attributes and relationships, adding (or overriding) its own. JSON:API
has no native subtyping, so the result is a distinct resource type: its own
type tag and branded id, with payloads and documents derived afresh. meta is
inherited unless overridden.
const Account = Resource.make("accounts", {
attributes: { email: Schema.NonEmptyString, createdAt: Schema.DateFromString },
relationships: { organisation: Relationship.one(() => Organisation) }
})
// `admins` inherits email, createdAt and organisation, adding `permissions`.
const Admin = Resource.extend(Account, "admins", {
attributes: { permissions: Schema.Array(Schema.String) }
})
Resource.attributeKeys(Admin) // ["email", "createdAt", "permissions"]2. Errors — declared once, spec-compliant forever
class ArticleNotFound extends ApiError.make<ArticleNotFound>()("ArticleNotFound", {
status: 404,
code: "not_found",
title: "Resource not found",
fields: { id: Schema.String }, // typed fields, round-tripped through the wire
detail: (e) => `Article ${e.id} not found`
}) {}One declaration gives you all of:
a tagged error class:
Effect.fail(new ArticleNotFound({ id })),Effect.catchTag("ArticleNotFound", …)a wire schema (
ArticleNotFound.wire) whose encoded form is a JSON:API error document:{ "errors": [ { "status": "404", "code": "not_found", "title": "Resource not found", "detail": "Article 42 not found", "meta": { "id": "42" } } ] }the HTTP status and OpenAPI documentation for free.
ApiError.BadRequest (400), ApiError.NotAcceptable (406), ApiError.UnsupportedMediaType (415),
ApiError.Forbidden (403) and ApiError.Conflict (409) are predefined.
3. Endpoints & groups — conventions baked in
const articles = Group.make(
Article,
// GET /articles/:id?include=author,tags&fields[articles]=title
Endpoint.get(Article, {
include: true,
fields: true,
errors: [ArticleNotFound]
}),
// GET /articles?sort=-createdAt&page[offset]=0&page[limit]=10&filter[author]=9
Endpoint.list(Article, {
include: true,
sort: ["createdAt", "title"],
page: Query.Page.Offset,
filter: { author: Schema.optionalKey(Schema.String) },
meta: Schema.Struct({ total: Schema.Int })
}),
// POST /articles → 201 (client may send a lid; the required author relationship must be present)
Endpoint.create(Article, { errors: [TitleTaken] }),
// PATCH /articles/:id (partial attributes)
Endpoint.update(Article, { errors: [ArticleNotFound] }),
// DELETE /articles/:id → 204
Endpoint.delete(Article, { errors: [ArticleNotFound] }),
// GET /articles/:id/comments — the paginated related collection
Endpoint.related(Article, "comments", {
page: Query.Page.Offset,
errors: [ArticleNotFound]
}),
// PATCH /articles/:id/relationships/author — replace the author
Endpoint.updateRelationship(Article, "author", { errors: [ArticleNotFound] })
)
const Api = HttpApi.make("blog").add(articles)Generating a whole group from a resource
Writing out every endpoint is explicit, but repetitive — a resource definition
already knows its attributes, relationships and graph. Group.resource walks
that definition and emits the entire group: the CRUD surface plus, for every
relationship, the related and linkage endpoints appropriate to its kind, with
include / fields / sort derived from the graph.
// CRUD + every relationship endpoint, fully typed — equivalent to spelling out
// get / list / create / update / delete and each relationship endpoint by hand:
const articles = Group.resource(Article, {
errors: [ArticleNotFound],
page: Query.Page.Offset,
// Per-endpoint config overrides the top-level defaults; the keys are the CRUD
// operations, the values a boolean (emit / omit) or that endpoint's options.
endpoints: {
create: { errors: [TitleTaken] },
list: { filter: { author: Schema.optionalKey(Schema.String) } }
}
})
// A read-only resource: just get + list, no relationship endpoints:
const people = Group.resource(Person, {
endpoints: { create: false, update: false, delete: false },
relationships: false
})
// Per-relationship config: drop one relationship, re-error another:
const issues = Group.resource(Issue, {
relationships: {
comments: false, // omit this relationship's endpoints
assignee: { errors: [UserNotFound] } // configure that relationship's endpoints
},
// `meta` may be a function, *extending* the resource's base meta rather than
// replacing it:
meta: (base) => Schema.Struct({ ...base.fields, total: Schema.Int })
})Defaults emit all five CRUD operations and every relationship's endpoints with
include / fields / sort enabled; page and filter stay opt-in (their
semantics are application-defined), and errors is applied to every generated
endpoint. Every default is overridable, globally or per endpoint / relationship
— see Endpoint.ResourceOptions.
For finer control — adding a heterogeneous search, dropping or replacing an
individual endpoint — Endpoint.resource returns the same endpoints as a plain
tuple to spread into Group.make:
const articles = Group.make(
Article,
...Endpoint.resource(Article, { errors: [ArticleNotFound] }),
// …plus anything else this group should serve
Endpoint.list(Article, { name: "search", path: "/articles/search", filter: { q: Schema.String } })
)Relationship & related endpoints
The spec defines two URL families per relationship; both are first-class:
| Constructor | Method & path | Payload | Success |
|---|---|---|---|
Endpoint.related |
GET /<type>/:id/<name> |
— | 200 — the related resource(s) themselves: a single-resource document (to-one) or a collection document with full query support (to-many) |
Endpoint.getRelationship |
GET /<type>/:id/relationships/<name> |
— | 200 — a linkage document (data is identifiers, never full resources) |
Endpoint.updateRelationship |
PATCH /<type>/:id/relationships/<name> |
replacement linkage | 200 — the updated linkage |
Endpoint.addRelationship |
POST /<type>/:id/relationships/<name> |
identifiers to add | 200 — the resulting linkage (to-many only) |
Endpoint.removeRelationship |
DELETE /<type>/:id/relationships/<name> |
identifiers to remove | 204 (to-many only) |
Payload and success schemas follow the relationship's kind:
// `author` is Relationship.one(() => Person):
Endpoint.updateRelationship(Article, "author")
// PATCH payload: { data: PersonIdentifier } — null doesn't decode (required relationship)
// `editor` is Relationship.optional(() => Person):
Endpoint.updateRelationship(Article, "editor")
// PATCH payload: { data: PersonIdentifier | null } — null clears the relationship
// `comments` is Relationship.paginated(() => Comment):
Endpoint.related(Article, "comments", { page: Query.Page.Offset, include: true })
// GET /articles/:id/comments?page[offset]=0&page[limit]=10&include=author
// → a paginated collection document of full Comment resources
Endpoint.addRelationship(Article, "comments")
// POST payload: { data: CommentIdentifier[] }
// to-many constructors only accept to-many relationship names:
Endpoint.addRelationship(Article, "author") // ✗ compile errorHandlers return linkage documents with Handlers.linkage, and build the
relationship URLs with Handlers.relationshipLink / Handlers.relatedLink /
Handlers.paginatedRelationship:
.handle("commentsRelationship", ({ params, query }) =>
loadComments(params.id, query.page).pipe(Effect.map((comments) =>
Handlers.linkage(comments.map((c) => Comment.ref(c.id)), {
self: Handlers.relationshipLink("articles", params.id, "comments"),
related: Handlers.relatedLink("articles", params.id, "comments")
})
)))Heterogeneous endpoints (search, feeds)
Endpoint.collection builds collection endpoints whose data mixes several resource types,
discriminated by their type tags — the natural fit for search results, feeds and timelines.
A polymorphic collection has no single owning resource, so name and path are required:
const search = Group.make(
"search",
// GET /search?filter[q]=bikeshed&include=author&page[offset]=0&page[limit]=10
Endpoint.collection([Article, Person], {
name: "search",
path: "/search",
filter: { q: Schema.String },
include: true, // include paths span both resources' graphs
fields: true, // ?fields[articles]= and ?fields[people]=
page: Query.Page.Offset,
meta: Schema.Struct({ total: Schema.Int })
})
)
const Api = HttpApi.make("blog").add(articles).add(search)
// Handlers return mixed collections; clients discriminate on `type`:
for (const result of doc.data) {
if (result.type === "articles")
result.attributes.title // Article
else result.attributes.firstName // Person
}The included union spans every searched resource's relationship targets, and query features
(fields[TYPE], include, sort) are derived across all of the resources in the union.
Atomic operations
Endpoint.operations models the atomic operations extension:
one request carrying an ordered list of operations — creating, updating and deleting resources or
their relationships — processed all-or-nothing:
const operations = Group.make(
"operations",
// POST /operations with an atomic:operations document
Endpoint.operations([Article, Comment], { errors: [OperationFailed] })
)
const Api = HttpApi.make("blog").add(articles).add(operations)Like everything else, the operations a resource supports are derived from its definition —
Atomic.operationsFor(Article) exposes them as a named record of schemas:
| Derived operation | Wire form |
|---|---|
.add |
{ op: "add", data: { type, lid?, attributes, relationships } } — one relationships required, paginated excluded |
.update |
{ op: "update", data: { type, id | lid, attributes?, relationships? } } — paginated excluded |
.remove |
{ op: "remove", ref: { type, id | lid } } |
.relationships.author.update (per one relationship) |
{ op: "update", ref: { type, id | lid, relationship }, data: ref } — never null |
.relationships.editor.update (per optional relationship) |
{ op: "update", ref: { type, id | lid, relationship }, data: ref | null } |
.relationships.comments.add / .update / .remove (per many / paginated relationship) |
{ op, ref: { type, id | lid, relationship }, data: [refs] } |
paginated relationships — which carry no inline linkage — are managed exactly this way: their
membership is changed through relationship operations (or relationship endpoints), never inside a
resource's relationships member.
Clients build requests with the typed operation constructors — note the lid refs
(Article.lidRef / Comment.lidRef) linking operations within the same request:
const doc =
yield *
client.operations.operations({
payload: Atomic.request(
// 1. create an article; it has no id yet, so it declares a lid.
// `author` is a required (`one`) relationship, so it must be present.
Atomic.add(Article, {
lid: "a1",
attributes: { title: "Atomic bikeshedding", body: "…", createdAt: new Date() },
relationships: {
author: { data: Person.ref("9") },
tags: { data: [Tag.ref("1")] }
}
}),
// 2. create a comment...
Atomic.add(Comment, {
lid: "c1",
attributes: { body: "First!" },
relationships: { author: { data: Person.ref("9") } }
}),
// 3. ...and link it into the new article's paginated comments relationship —
// both sides referenced by lid
Atomic.addToRelationship(Article, { lid: "a1" }, "comments", [Comment.lidRef("c1")]),
// 4. to-one relationship operations replace linkage (`one`: never null)
Atomic.updateRelationship(Comment, "5", "author", Person.ref("9"))
)
})
doc["atomic:results"] // one result per operation, in order; `data` is typed Article | CommentHandlers pattern-match over the decoded operation union — the targetsResource /
targetsRelationship guards are curried, so they drop straight into Effect's Match module and
narrow each case to fully typed data / ref; Lid.make() tracks the server-assigned ids
of lid-created resources:
const OperationsLive = HttpApiBuilder.group(Api, "operations", (handlers) =>
handlers.handle("operations", ({ payload }) =>
Effect.gen(function*() {
const lids = Lid.make()
const entries = []
for (const operation of payload["atomic:operations"]) {
entries.push(Match.value(operation).pipe(
Match.when(Atomic.targetsRelationship(Article, "comments"), (op) => {
// op.data is ReadonlyArray<comment ref>; op.op is "add" | "update" | "remove"
return Atomic.emptyResult
}),
Match.when(Atomic.targetsResource(Article), (op) =>
Match.value(op).pipe(
Match.when({ op: "add" }, (add) => {
const id = Article.Id.make(newId())
const resolved = lids.resolveLinkage(Article, add.data.relationships) // lids → real ids
const article = Article.make({
id,
attributes: add.data.attributes,
relationships: {
// `author` is required (`one`), so the operation always carries it
author: { data: lids.identifier(Person, add.data.relationships.author.data) },
tags: resolved.tags ?? { data: [] },
// `comments` is paginated: new articles start with an empty collection
comments: Handlers.paginatedRelationship("articles", id, "comments")
}
})
if (add.data.lid !== undefined) lids.assign(add.data.lid, article.id)
return { data: article }
}),
Match.when({ op: "update" }, (update) => /* … */),
Match.when({ op: "remove" }, (remove) => /* … */),
Match.exhaustive
)),
// … one case per resource and relationship; Match.exhaustive proves
// every operation in the union is handled
Match.exhaustive
))
}
return Atomic.results(entries)
})))Because the extension uses the JSON:API media type with an ext parameter, provide the
middleware with the extension declared:
Layer.provide(Middleware.layerWith({ extensions: [Atomic.EXTENSION_URI] }))Every endpoint automatically:
- serves and accepts
application/vnd.api+json - declares its errors as JSON:API error documents at the right status
- carries the content-negotiation middleware (415 on parameterised request media types, 406 on
unacceptable
Acceptheaders) and the schema-error middleware (malformed params/query/payloads become JSON:API 400 documents) — and because they're realHttpApiMiddlewareservices, the api won't build until you provide them: forgetting is a compile error, not a runtime surprise - documents itself in OpenAPI (
OpenApi.fromApi(Api)) with the JSON:API media type, status codes and bracket query parameters
4. Handlers — typed in, validated out
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
const ArticlesLive = HttpApiBuilder.group(Api, "articles", (handlers) =>
handlers
.handle("get", ({ params, query }) =>
// ^ params.id is a branded Article id
// ^ query.include / query.fields are typed & validated
loadArticle(params.id).pipe(
Effect.map((article) =>
Handlers.data(article, {
included: resolveIncluded(article, query.include),
self: `/articles/${article.id}`
})
)
))
.handle("list", ({ query }) =>
// query.sort: [{ field: "createdAt", direction: "desc" }]
// query.page: { offset?: number, limit?: number }
listArticles(query).pipe(
Effect.map(({ items, total }) =>
Handlers.collection(items, {
meta: { total },
links: Handlers.offsetPaginationLinks("/articles", query.page ?? {}, total)
})
)
))
.handle("create", ({ payload }) =>
// payload.data.attributes is fully typed; payload.data.lid is supported
createArticle(payload.data).pipe(Effect.map((article) => Handlers.data(article))))
.handle("update", ({ params, payload }) => /* … */)
.handle("delete", ({ params }) => deleteArticle(params.id)) // void → 204
)The document builders (Handlers.data / Handlers.collection) enforce the compound-document rules
at runtime: included is deduplicated by (type, id) and checked for full linkage (every
included resource must be referenced in the document).
Pagination links are built with Handlers.offsetPaginationLinks (for Page.Offset) and
Handlers.numberPaginationLinks (for Page.Number), which emit the spec's first / prev /
next / last top-level links from the request's page parameters and the total count.
To name the value a builder returns — e.g. on a helper that assembles documents outside a handler —
use Handlers.DocumentValue<Data, Included?, Meta?> (the runtime shape, with an optional jsonapi
member) or the schema-derived Document.Value<R, Included?, Meta?>, instead of hand-rolling the
{ data, included?, links?, meta?, jsonapi? } envelope.
To serve it (with @effect/platform-node):
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
HttpApiBuilder.layer(Api).pipe(
Layer.provide(ArticlesLive),
Layer.provide(Middleware.layer), // content negotiation + JSON:API 400s
Layer.provide(NodeHttpServer.layer(...)),
Layer.launch,
NodeRuntime.runMain
)If you negotiate content outside HttpApi — say in a framework hook that owns the URL —
Middleware.negotiate(headers, options?) runs the §5 rules standalone, returning the offending
ApiError (UnsupportedMediaType / NotAcceptable) or undefined, and ApiError.toDocument(error)
renders any ApiError to a JSON:API error-document value:
const error = Middleware.negotiate({
contentType: request.headers.get("content-type") ?? undefined,
accept: request.headers.get("accept") ?? undefined
})
if (error) {
const body = ApiError.toDocument(error) // { errors: [{ status, code, title }] }
// → respond with `Number(body.errors[0].status)` and `body`
}And to call it — the same definitions drive a fully typed client:
import { HttpApiClient } from "effect/unstable/httpapi"
const client = yield* HttpApiClient.make(Api, { baseUrl: "http://localhost:3000" })
const doc = yield* client.articles.get({
params: { id: Article.Id.make("1") },
query: { include: ["author"] } // ← include paths are typed literals; typos don't compile
}).pipe(
Effect.catchTag("ArticleNotFound", (e) => /* e.id is typed */ …)
)
// doc.data.attributes.createdAt is a Date; doc.included is typedNarrowing included by the requested include paths
The spec guarantees that a server "MUST NOT include unrequested resource objects", so the client
knows statically what included can contain — Client.narrowIncluded exposes that:
const include = ["author"] as const
const doc =
yield *
client.articles
.get({
params: { id: Article.Id.make("1") },
query: { include }
})
.pipe(Client.narrowIncluded(Article, include))
doc.included
// ^ ReadonlyArray<Person> — not Person | Comment | Tag.
// doc.included[0].attributes.firstName is accessible without narrowing on `type`.Dotted paths include the intermediate resources (["comments.author"] → Comment | Person), and
requesting nothing narrows included to never. This is a type-level operation with no runtime
cost; the response is still decoded against the endpoint's full schema, so a non-compliant server
fails loudly instead of lying.
Query parameters
| Family | Wire form | Decoded form |
|---|---|---|
include |
?include=author,comments.author |
ReadonlyArray<"author" | "comments" | "comments.author"> — literal paths from the relationship graph |
fields[TYPE] |
?fields[articles]=title,body |
{ articles?: ReadonlyArray<"title" | "body" | …> } — closed per-type key sets |
sort |
?sort=-createdAt,title |
[{ field: "createdAt", direction: "desc" }, …] |
page[*] |
?page[offset]=0&page[limit]=10 |
{ offset?: number, limit?: number } (Page.Offset, Page.Number, Page.Cursor, or custom) |
filter[*] |
?filter[author]=9 |
user-defined schema per filter key |
Unknown include paths, unknown sparse-fieldset names and unknown sort fields fail decoding — which the schema-error middleware turns into a spec-compliant 400 JSON:API error document.
Page.Offset / Page.Number / Page.Cursor are the ready-made constants. For a bounded,
defaulted, dual-use variant, call Query.Page.offset(options) (and, for page-number pagination,
Query.Page.number(options)): it returns the same { offset, limit } field-map but lets you cap
limit with maxLimit (a DoS guard every JSON:API server wants), fill in defaultLimit /
defaultOffset on decode, and — with fromString: false — build the fields from plain
Schema.Number instead of FiniteFromString, so the one schema works both as a numeric call-site
input and behind a transport that coerces query strings to numbers (e.g. raw HttpApiEndpoint
wrapped in Schema.toCodecStringTree).
// caps page size at 100, defaults to 25, still decodes from query strings
Endpoint.list(Article, { page: Query.Page.offset({ maxLimit: 100, defaultLimit: 25 }) })Spec compliance, by construction
| JSON:API v1.1 rule | How it's enforced |
|---|---|
Media type application/vnd.api+json |
baked into every document/payload/error schema |
415/406 on media type parameters other than ext / profile (or unsupported extensions) |
middleware attached to every endpoint; providing it is required by the type system |
| Error bodies are error documents | only ApiError.make classes can be declared as endpoint errors |
Top-level document holds exactly one of data / errors / meta |
success schemas only ever contain data; error schemas only errors — mixing is unrepresentable |
null primary data only for a single-resource URL that might correspond to a resource but currently doesn't |
DataDocument is a pure envelope: DataDocument(R) is non-null; opt into data: null with Schema.NullOr(R) / R.nullable(). Endpoint.related keeps the nullable form for a to-one relationship |
Resource objects have type and id; ids are not interchangeable across types |
Resource always emits the type tag and a per-type branded id |
Create requests may omit id and send lid |
createPayload derivation |
Update requests require id, attributes are partial |
updatePayload derivation |
Relationships hold at least one of data / links / meta |
one / optional / many schemas require resource linkage (data); paginated schemas require links.related |
| Relationship endpoints: GET/PATCH on to-one, GET/POST/PATCH/DELETE on to-many | Endpoint.getRelationship / updateRelationship / addRelationship / removeRelationship; add/remove only constructible for to-many relationships |
Related resource endpoints (related links) |
Endpoint.related — single-resource document with nullable data for to-one (empty-linkage case), paginated collection for to-many |
| Compound documents: no duplicate resources, full linkage | Handlers.data / Handlers.collection builders (runtime check) |
| Compound documents never inline unbounded relationships | paginated relationships are excluded from ?include= paths and included unions by construction |
errors array is never empty |
non-empty check on the error document schema |
| 200 / 201 / 204 status codes per operation | set by the endpoint constructors |
| Pagination / sorting / sparse fieldsets / inclusion / filtering query families | typed query schemas derived from the resource definition |
Atomic operations extension: atomic:operations / atomic:results documents, lid refs, relationship operations |
Endpoint.operations + Atomic schemas derived from resource definitions |
Examples
Complete runnable examples (resources, errors, api, in-memory handlers) live in
examples. Each is a standalone package in the pnpm workspace —
it depends on @thomasfosterau/effect-jsonapi and carries its own tests, so you
can lift any one out as a starting point. Run them all from the repo root with
pnpm test, or a single one with pnpm --filter @thomasfosterau/effect-jsonapi-example-northwind test.
| Example | What it shows | Test |
|---|---|---|
examples/blog |
The classic JSON:API blog: articles, people, tags, comments; full CRUD; a required (one) author, inlined (many) tags and a paginated comment feed with attach/detach relationship endpoints; heterogeneous search; an atomic operations endpoint with all-or-nothing semantics and lid resolution |
blog/test |
examples/github |
A GitHub-like API: users, repositories, issues, issue comments, pull requests, labels; all four relationship kinds (required owner/author, nullable assignee, inlined labels, paginated comments); issue triage via relationship endpoints (assign/unassign, add/remove/replace labels); 2-hop include paths (repository.owner); per-group endpoint subsets; typed filters over closed attribute sets; page-number pagination; 403/404/422 domain errors; global search across three resource types |
github/test |
examples/northwind |
A Northwind Traders e-commerce API: categories, suppliers, shippers, customers, territories, employees, products, orders and line items; all four relationship kinds laid out as an acyclic graph (required category/supplier/customer/employee, nullable shipper, inlined territories, a paginated line-item feed); the reverse directions (a category's products, a customer's orders) and the self-referential reporting hierarchy modelled as filter[…] collection endpoints; product CRUD with typed numeric price-range filters; offset/limit pagination; territory assignment and order shipping via relationship endpoints; 404/409 domain errors; global catalog search across three resource types |
northwind/test |
Metadata
meta is free-form by spec, so every meta member accepts arbitrary records by default. Tighten
any of them by passing a schema:
// Resource-level meta (on every resource object)
const Article = Resource.make("articles", {
attributes: { … },
meta: Schema.Struct({ rank: Schema.Int })
})
// Document-level meta (per endpoint, e.g. pagination totals)
Endpoint.list(Article, { meta: Schema.Struct({ total: Schema.Int }) })
// Or on a document schema directly
Article.collection({ meta: Schema.Struct({ total: Schema.Int }) })Relationship and resource-identifier meta currently accept free-form records (untyped).
Limitations
- Sparse fieldsets are advisory:
?fields[TYPE]=is decoded, validated and handed to your handler, but attribute projection in responses is handler logic (automatic projection would require post-processing responses against request state). - Include paths are typed to a depth of 2 hops (
"comments.author"works,"comments.author.employer"doesn't) — a TypeScript recursion-depth trade-off. The runtime validation matches the same set. - Server-side
includednarrowing is not possible: a handler's return type cannot depend on the runtime value of?include=(that's dependent typing). Client-side narrowing is provided viaClient.narrowIncluded. - Relationship and identifier
metaare untyped (free-form records); resource and document meta are typed via options. - Mutually recursive resources (A B) are not supported: TypeScript cannot infer two resource
types that reference each other. Model one direction as a relationship and the reverse direction
as a filtered collection endpoint (e.g.
GET /articles?filter[author]=9) or a related endpoint on the side that owns the relationship. narrowIncludedis single-resource: narrowing theincludedof heterogeneous (search) responses by include paths is not yet supported.- Atomic operations requests are accepted with or without the
extmedia type parameter: spec-compliant clients sendapplication/vnd.api+json;ext="https://jsonapi.org/ext/atomic"(accepted once the middleware declares the extension), but the bare media type is not rejected. Responses always carry the parameter.
Contributing
Contributions are welcome! See CONTRIBUTING.md.
License
MIT Thomas Foster