@leodamours/jsonapi-dsl
v1.1.0
Published
A TypeScript-first DSL for JSON:API — typed resource definitions, relationships, and inference
Maintainers
Readme
@leodamours/jsonapi-dsl
A TypeScript-first DSL for JSON:API — define resources once, infer every type.
Zero dependencies. Zero runtime overhead beyond tiny marker objects.
Install
npm install @leodamours/jsonapi-dslQuick Start
import { defineResource, t, hasOne, hasMany } from "@leodamours/jsonapi-dsl";
import type { InferResource, JsonApiDocument } from "@leodamours/jsonapi-dsl";
const User = defineResource("users", {
name: t.string(),
email: t.string(),
age: t.nullable(t.number()),
});
const Post = defineResource(
"posts",
{
title: t.string(),
body: t.string(),
publishedAt: t.nullable(t.date()),
tags: t.array(t.string()),
},
{
author: hasOne("users"),
comments: hasMany("comments"),
}
);
// Full resource type inferred from definition
type PostResource = InferResource<typeof Post>;
// Use in API response typing
type PostDocument = JsonApiDocument<PostResource>;Type Builders
| Builder | TypeScript type | Example |
|---------|----------------|---------|
| t.string() | string | name: t.string() |
| t.number() | number | age: t.number() |
| t.boolean() | boolean | active: t.boolean() |
| t.date() | Date | createdAt: t.date() |
| t.nullable(inner) | T \| null | bio: t.nullable(t.string()) |
| t.array(inner) | T[] | tags: t.array(t.string()) |
| t.optional(inner) | T \| undefined | nickname: t.optional(t.string()) |
Builders compose — t.nullable(t.array(t.string())) infers as string[] | null.
Relationships
import { hasOne, hasMany } from "@leodamours/jsonapi-dsl";
const Article = defineResource(
"articles",
{ title: t.string() },
{
author: hasOne("users"), // { data: { type: "users"; id: string } | null }
comments: hasMany("comments"), // { data: { type: "comments"; id: string }[] }
}
);Relationship types enforce literal type strings — hasOne("users") won't accept { type: "people" }.
Polymorphic Relationships
Relationships can point to multiple resource types by passing an array:
const Comment = defineResource(
"comments",
{ body: t.string() },
{
commentable: hasOne(["posts", "articles"]), // { type: "posts" | "articles"; id: string } | null
mentions: hasMany(["users", "teams"]), // { type: "users" | "teams"; id: string }[]
}
);Literal types are inferred automatically — no as const needed on the array.
Per-Verb Attribute Narrowing
Control which attributes are writable for create vs update:
const Author = defineResource(
"authors",
{
name: t.string(),
email: t.string(),
createdAt: t.string(), // read-only, set by server
},
{
posts: hasMany("posts"),
},
{
create: ["name", "email"] as const, // createdAt excluded from create
update: ["name"] as const, // only name editable on update
}
);When no options are provided, all attributes are writable (backward compatible).
This narrowing is consumed by @leodamours/jsonapi-client's CreatePayload and UpdatePayload types.
Type-Safe Query Helpers
The DSL exports utility types for type-safe query parameter construction:
import type { IncludePath, SortField } from "@leodamours/jsonapi-dsl";
// IncludePath — validates relationship names (first segment of dot-paths)
type PostIncludes = IncludePath<typeof Post["relationships"]>;
// "author" | "comments" | `author.${string}` | `comments.${string}`
// SortField — attribute names with optional `-` prefix for descending
type PostSortFields = SortField<typeof Post["attributes"]>;
// "title" | "body" | "publishedAt" | "tags" | "-title" | "-body" | "-publishedAt" | "-tags"These types are consumed by @leodamours/jsonapi-client's TypedQueryParams, but can be used standalone with any HTTP layer.
Inference Utilities
import type {
InferResource,
InferResourceAttributes,
InferResourceRelationships,
InferResourceType,
InferType,
InferAttributes,
} from "@leodamours/jsonapi-dsl";
type PostResource = InferResource<typeof Post>;
// { type: "posts"; id?: string; attributes: { title: string; ... }; relationships: { ... } }
type PostAttrs = InferResourceAttributes<typeof Post>;
// { title: string; body: string; publishedAt: Date | null; tags: string[] }
type PostRels = InferResourceRelationships<typeof Post>;
// { author: JsonApiRelationship & { data: { type: "users"; id: string } | null }; ... }
type PostType = InferResourceType<typeof Post>;
// "posts"Type Guards
Use isResourceOf to narrow JsonApiResource values to their inferred type — useful for filtering the included array in JSON:API responses:
import { isResourceOf } from "@leodamours/jsonapi-dsl";
const included: JsonApiResource[] = response.included ?? [];
const users = included.filter(isResourceOf(User));
// ^? InferResource<typeof User>[]
const posts = included.filter(isResourceOf(Post));
// ^? InferResource<typeof Post>[]
// Attributes are fully typed after narrowing
users[0].attributes.name; // string
users[0].attributes.email; // stringWorks with any JsonApiResource, including in conditionals:
for (const resource of included) {
if (isResourceOf(User)(resource)) {
resource.attributes.name; // string
}
}Response Deserialization
Flatten JSON:API documents into plain objects. Works with any HTTP layer — no client dependency required:
import { deserialize, deserializeMany } from "@leodamours/jsonapi-dsl";Without registry — relationships stay as { type, id } linkage:
const { data: post } = deserialize(Post, document);
post.title; // string (was document.data.attributes.title)
post.author; // { type: "users"; id: string } | nullWith registry — relationships resolved one level from included:
const { data: post } = deserialize(Post, document, {
users: User,
comments: Comment,
});
post.author.name; // string — resolved from included
post.comments[0].body; // string — resolved from includedCollections:
const { data: posts, meta, links } = deserializeMany(Post, collectionDoc, {
users: User,
});Resolution is one level deep: resolved relationships have their own relationships as { type, id } linkage.
Deserialization types:
import type { Deserialized, DeserializedWith } from "@leodamours/jsonapi-dsl";
type FlatPost = Deserialized<typeof Post>;
// { id: string; type: "posts"; title: string; author: { type: "users"; id: string } | null; ... }
type ResolvedPost = DeserializedWith<typeof Post, { users: typeof User }>;
// { id: string; type: "posts"; title: string; author: Deserialized<typeof User> | null; ... }JSON:API Core Types
The package also exports all standard JSON:API types:
import type {
JsonApiResource,
JsonApiDocument,
JsonApiCollectionDocument,
JsonApiErrorDocument,
JsonApiRelationship,
JsonApiLinks,
JsonApiMeta,
} from "@leodamours/jsonapi-dsl";License
MIT
