npm package: @leodamours/jsonapi-dsl
Description: A TypeScript-first DSL for JSON:API — typed resource definitions, relationships, and inference
Author: Leo Damours
Latest version: 1.1.0

# @leodamours/jsonapi-dsl

[![CI](https://github.com/LeoDamours/json-api-client/actions/workflows/ci.yml/badge.svg)](https://github.com/LeoDamours/json-api-client/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@leodamours/jsonapi-dsl)](https://www.npmjs.com/package/@leodamours/jsonapi-dsl)

A TypeScript-first DSL for [JSON:API](https://jsonapi.org/) — define resources once, infer every type.

Zero dependencies. Zero runtime overhead beyond tiny marker objects.

## Install

```bash
npm install @leodamours/jsonapi-dsl
```

## Quick Start

```ts
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

```ts
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:

```ts
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:

```ts
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:

```ts
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

```ts
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:

```ts
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; // string
```

Works with any `JsonApiResource`, including in conditionals:

```ts
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:

```ts
import { deserialize, deserializeMany } from "@leodamours/jsonapi-dsl";
```

**Without registry** — relationships stay as `{ type, id }` linkage:

```ts
const { data: post } = deserialize(Post, document);
post.title;               // string (was document.data.attributes.title)
post.author;              // { type: "users"; id: string } | null
```

**With registry** — relationships resolved one level from `included`:

```ts
const { data: post } = deserialize(Post, document, {
  users: User,
  comments: Comment,
});
post.author.name;         // string — resolved from included
post.comments[0].body;    // string — resolved from included
```

**Collections:**

```ts
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:**

```ts
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:

```ts
import type {
  JsonApiResource,
  JsonApiDocument,
  JsonApiCollectionDocument,
  JsonApiErrorDocument,
  JsonApiRelationship,
  JsonApiLinks,
  JsonApiMeta,
} from "@leodamours/jsonapi-dsl";
```

## License

MIT