# dotschema

> Import a JSON Schema from a .schema.jsonc file into TypeScript, and get the type it describes and a validator compiled by ajv at build time. A TypeScript 7.1 content mapper.

Import JSON Schemas into TypeScript. Fully typed, scary fast.

If you have a JSON Schema like this:

`user.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "role": { "enum": ["admin", "member"] }
  },
  "required": ["name"],
  "additionalProperties": false
}
```

You can import it directly from a TypeScript file, and get its type and a validator:

`main.ts`:

```ts
import user from "./user.schema.jsonc";

const data = await request.json();

if (user.is(data)) {
  data; // hover over data to see its type
}
```

It's powered by TypeScript's [_content mappers_](https://github.com/microsoft/typescript-go/pull/4712) (introduced in TypeScript 7.1), which let a package give `tsc` the types of a file it imports. The validator is compiled by [ajv](https://ajv.js.org) when the module is built, so a bundle carries the validator and no validator library.

## Setup

Install dotschema, then add it to TypeScript and to each tool that loads your code.

### Install

You'll need:

- **TypeScript 7.1 (currently in beta).** The first release with content mappers.
- **Node 24 or later.**

```sh
npm install -D dotschema typescript@beta
```

If your app runs under the preload, not as a bundle, dotschema builds each schema's module at run time, so install it as a dependency:

```sh
npm install dotschema
npm install -D typescript@beta
```

### TypeScript

Add dotschema to `tsconfig.json` as a content mapper:

`tsconfig.json`:

```jsonc
{
  "compilerOptions": { /* ... */ },
  "contentMappers": [
    { "package": "dotschema", "extensions": [".schema.jsonc"] }
  ]
}
```

Then type-check with `--runExternalCode`, which lets `tsc` run content mappers:

`package.json`:

```jsonc
{
  "scripts": {
    "typecheck": "tsc --noEmit --runExternalCode"
  }
}
```

### Plugins

Install the plugin for your runtime or framework of choice:

**Node**

Load dotschema's preload with `--import`, which turns each schema file into a module as it loads:

```sh
node --import dotschema/register main.ts
```

**Bun**

Add the preload to `bunfig.toml`, and under `[test]` for `bun test`:

`bunfig.toml`:

```toml
preload = ["dotschema/register"]

[test]
preload = ["dotschema/register"]
```

For Bun's bundler, use the esbuild plugin:

`build.ts`:

```ts
import dotschema from "dotschema/esbuild";

await Bun.build({
  entrypoints: ["main.ts"],
  target: "bun",
  plugins: [dotschema()],
  outdir: "dist",
});
```

**Nub**

Add the preload to `nub.jsonc`, for `nub main.ts` and `nub --test`:

`nub.jsonc`:

```jsonc
{
  "preload": ["dotschema/register"]
}
```

**Vite**

Add the plugin to your Vite config, which builds each schema's module in the dev server and in the build, for the browser and for the server:

`vite.config.ts`:

```ts
import { defineConfig } from "vite";
import dotschema from "dotschema/vite";

export default defineConfig({
  plugins: [dotschema()],
});
```

For Vitest, this plugin is all you need. Rollup and Rolldown take it too:

`rollup.config.mjs`:

```js
import dotschema from "dotschema/vite";

export default {
  input: "main.js",
  plugins: [dotschema()],
  output: { file: "dist/main.mjs", format: "es" },
};
```

**Next.js**

Wrap your Next.js config with `withDotschema`:

`next.config.ts`:

```ts
import type { NextConfig } from "next";
import { withDotschema } from "dotschema/next";

const nextConfig: NextConfig = {
  // ...
};

export default withDotschema(nextConfig);
```

Your server and client components can now import schemas, with Turbopack or webpack, and `next build` fails on a type error.

To type-check without building, run `next typegen` first, which writes the route types:

`package.json`:

```json
{
  "scripts": {
    "typecheck": "next typegen && tsc --noEmit --runExternalCode"
  }
}
```

**How the build checks the types**

Next.js type-checks without `--runExternalCode`, so `withDotschema` turns that check off and runs `tsc --noEmit --runExternalCode` inside `next build` in its place.

**Beside Motherload or Reylo**

Beside Motherload's `withMotherload` or Reylo's `withReylo`, the wrappers nest in either order:

`next.config.ts`:

```ts
export default withMotherload(withDotschema(nextConfig));
```

**esbuild**

Add the esbuild plugin, which builds each schema's module at build time, so a bundled app needs no preload:

`build.mjs`:

```js
import * as esbuild from "esbuild";
import dotschema from "dotschema/esbuild";

await esbuild.build({
  entryPoints: ["main.ts"],
  bundle: true,
  platform: "node",
  format: "esm",
  plugins: [dotschema()],
  outfile: "dist/main.mjs",
});
```

**webpack**

Load schema files with dotschema's loader:

`webpack.config.mjs`:

```js
export default {
  // ...
  module: {
    rules: [
      { test: /\.schema\.(jsonc|json5)$/, use: "dotschema/webpack" },
    ],
  },
};
```

**Rspack**

Load schema files with dotschema's loader:

`rspack.config.mjs`:

```js
export default {
  // ...
  module: {
    rules: [
      { test: /\.schema\.(jsonc|json5)$/, use: "dotschema/webpack" },
    ],
  },
};
```

**Cloudflare**

Add dotschema's Vite plugin beside Cloudflare's:

`vite.config.ts`:

```ts
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
import dotschema from "dotschema/vite";

export default defineConfig({
  plugins: [dotschema(), cloudflare()],
});
```

Deploy with `wrangler deploy` after `vite build`: Wrangler's own bundler takes no plugin.

**Jest**

Add dotschema's transformer to your Jest config:

`jest.config.js`:

```js
export default {
  transform: { "\\.schema\\.(jsonc|json5)$": "dotschema/jest" },
  extensionsToTreatAsEsm: [".jsonc", ".json5"],
};
```

```sh
# Jest runs ES modules only behind this Node flag
NODE_OPTIONS=--experimental-vm-modules npx jest
```

**Beside another transformer for .jsonc**

Jest runs the first pattern that matches a file, so beside another transformer for `.jsonc` or `.json5`, such as Motherload's, dotschema's pattern comes first:

`jest.config.js`:

```js
export default {
  transform: {
    "\\.schema\\.(jsonc|json5)$": "dotschema/jest",
    "\\.(jsonc|json5)$": "motherload/jest",
  },
};
```

### Editors

Set up your editor to use the TypeScript 7.1 language server, with content mappers on.

**VS Code**

1. Install the **TypeScript 7** extension (`TypeScriptTeam.native-preview`).
2. Point it at the project's TypeScript: the extension's own copy is TypeScript 7.0, which has no content mappers.

   ```json title=".vscode/settings.json"
   {
     "js/ts.experimental.useTsgo": true,
     "js/ts.tsdk.path": "./node_modules/typescript"
   }
   ```

3. Trust the workspace, and click **Allow** when the extension asks to use the workspace's TypeScript. Content mappers run in a trusted workspace only.

**Cursor**

1. Install the **TypeScript 7** extension (`TypeScriptTeam.native-preview`).
2. Point it at the project's TypeScript: the extension's own copy is TypeScript 7.0, which has no content mappers.

   ```json title=".vscode/settings.json"
   {
     "js/ts.experimental.useTsgo": true,
     "js/ts.tsdk.path": "./node_modules/typescript"
   }
   ```

3. Trust the workspace, and click **Allow** when the extension asks to use the workspace's TypeScript. Content mappers run in a trusted workspace only.

**Zed**

1. Install the **TypeScript Language Server** extension by Zed Industries (search for "tsgo").
2. Point it at the project's TypeScript, with content mappers on:

   ```jsonc title=".zed/settings.json"
   {
     "languages": {
       "TypeScript": {
         "language_servers": [
           "typescript-ls",
           "!vtsls",
           "!typescript-language-server",
           "..."
         ]
       },
       "TSX": {
         "language_servers": [
           "typescript-ls",
           "!vtsls",
           "!typescript-language-server",
           "..."
         ]
       }
     },
     "lsp": {
       "typescript-ls": {
         // the extension's own download is TypeScript 7.0, which has no content mappers
         "binary": {
           "path": "node_modules/typescript/bin/tsc",
           "arguments": ["--lsp", "--stdio"]
         },
         "initialization_options": { "runExternalCode": true }
       }
     }
   }
   ```

3. Trust the folder.

Zed doesn't send schema files to the language server, so an error inside one shows only in the type check.

## Schema files

A schema file is a JSON Schema written as JSONC, so it can hold comments and trailing commas:

`user.schema.jsonc`:

```jsonc
{
  // who can sign in
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "role": { "enum": ["admin", "member"] },
  },
  "required": ["name"],
  "additionalProperties": false,
}
```

> A schema can also be written in JSON5, as a `.schema.json5` file: add `.schema.json5` to the `extensions` list in `tsconfig.json`.

The `$schema` keyword picks the draft, 06, 07, 2019-09 or 2020-12, and a schema without it is draft-07:

```jsonc
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "string"
}
```

A `$ref` can point to another schema file, by a path relative to this one:

`team.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    "members": {
      "type": "array",
      "items": { "$ref": "./user.schema.jsonc" }
    }
  },
  "required": ["members"],
  "additionalProperties": false
}
```

The type imports the other file's, by its name:

`main.ts`:

```ts
import team from "./team.schema.jsonc";

type Members = team.Type["members"];
//   ^? type Members = User[]
```

A `$ref` with a fragment points into the other file, to a definition or an anchor:

```jsonc
{ "$ref": "./place.schema.jsonc#/$defs/address" }
```

A keyword the schema's draft doesn't define is ignored, by the type and by the validator, so a schema written for another validator still compiles:

`account.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    "id": { "type": "string", "x-internal": true, "tsType": "AccountId" }
  },
  "required": ["id"],
  "additionalProperties": false
}
```

`main.ts`:

```ts
import account from "./account.schema.jsonc";

type Id = account.Type["id"];
//   ^? type Id = string
```

## The type

The type admits every value the schema allows. A keyword no TypeScript type can express, such as `minLength` or `format`, leaves the type as it is, and the validator checks it.

The type is `.Type` on the import:

`main.ts`:

```ts
import user from "./user.schema.jsonc";

const ada: user.Type = { name: "Ada", role: "admin" };
const bob: user.Type = { name: "Bob", role: "owner" };
```

A `/** */` comment above a property is its doc comment, which an editor shows on hover:

`user.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    /** The name the user signs in with. */
    "name": { "type": "string", "minLength": 1 },
    "role": { "enum": ["admin", "member"] }
  },
  "required": ["name"],
  "additionalProperties": false
}
```

`main.ts`:

```ts
import user from "./user.schema.jsonc";

if (user.is(input)) {
  input.name;
  //    ^? name: string
}
```

An `enum` or a `const` is a literal type:

`post.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    "status": { "enum": ["draft", "published"] },
    "version": { "const": 1 }
  },
  "required": ["status", "version"],
  "additionalProperties": false
}
```

`main.ts`:

```ts
import post from "./post.schema.jsonc";

if (post.is(input)) {
  input;
//^? const input: {
//       status: "draft" | "published";
//       version: 1;
//   }
}
```

A schema in `additionalProperties` types every other key:

`scores.schema.jsonc`:

```jsonc
{
  "type": "object",
  "additionalProperties": { "type": "number" }
}
```

`main.ts`:

```ts
import scores from "./scores.schema.jsonc";

if (scores.is(input)) {
  input;
//^? const input: {
//       [key: string]: number;
//   }
}
```

A `prefixItems` list is a tuple, its items required up to `minItems`:

`point.schema.jsonc`:

```jsonc
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "array",
  "prefixItems": [{ "type": "number" }, { "type": "number" }],
  "minItems": 2,
  "items": false
}
```

`main.ts`:

```ts
import point from "./point.schema.jsonc";

type Point = point.Type;
//   ^? type Point = [number, number]
```

A `oneOf` whose variants a `const` tells apart is a union that narrows:

`event.schema.jsonc`:

```jsonc
{
  "type": "object",
  "oneOf": [
    {
      "properties": { "kind": { "const": "click" }, "x": { "type": "number" } },
      "required": ["kind", "x"]
    },
    {
      "properties": { "kind": { "const": "visit" }, "url": { "type": "string" } },
      "required": ["kind", "url"]
    }
  ]
}
```

`main.ts`:

```ts
import event from "./event.schema.jsonc";

if (event.is(input)) {
  if (input.kind === "click") {
    input.x;
    //    ^? x: number
  } else {
    input.url;
    //    ^? url: string
  }
}
```

An `if` that tests a property's listed values narrows the same way:

`payment.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": { "method": { "enum": ["card", "bank"] } },
  "required": ["method"],
  "if": { "properties": { "method": { "const": "card" } } },
  "then": { "properties": { "last4": { "type": "string" } }, "required": ["last4"] },
  "else": { "properties": { "iban": { "type": "string" } }, "required": ["iban"] }
}
```

`main.ts`:

```ts
import payment from "./payment.schema.jsonc";

if (payment.is(input) && input.method === "card") {
  input.last4;
  //    ^? last4: string
}
```

## Validating

The validator is compiled by [ajv](https://ajv.js.org) when the module is built. Nothing on the module throws.

### `.validate()`

The `.validate()` method returns the value, or every issue, each with its message, its path and the keyword that failed:

`main.ts`:

```ts
import user from "./user.schema.jsonc";

const result = user.validate(input);
if (result.issues) {
  console.log(result.issues);
  // for { name: "" }:
  // [{
  //   message: "must NOT have fewer than 1 characters",
  //   path: ["name"],
  //   keyword: "minLength",
  // }]
} else {
  result.value;
  //     ^? value: {
  //            name: string;
  //            role?: "admin" | "member";
  //        }
}
```

### `.is()`

The `.is()` method is a type guard, which stops at the first failure:

`main.ts`:

```ts
import user from "./user.schema.jsonc";

const users = values.filter(user.is);
//    ^? const users: {
//           name: string;
//           role?: "admin" | "member";
//       }[]
```

### Definitions

Each entry of `$defs` is a validator of its own, imported by its key:

`place.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": { "home": { "$ref": "#/$defs/address" } },
  "$defs": {
    "address": {
      "type": "object",
      "properties": { "city": { "type": "string" } },
      "required": ["city"],
      "additionalProperties": false
    }
  }
}
```

`main.ts`:

```ts
import { address } from "./place.schema.jsonc";

if (address.is(input)) {
  input;
//^? const input: {
//       city: string;
//   }
}
```

### `.pick()` and `.omit()`

The `.pick()` method gives the schema with only the properties named, and `.omit()` the schema without them:

`main.ts`:

```ts
import user from "./user.schema.jsonc";

const named = user.pick("name");
named.validate({ name: "Ada", role: "admin" });
// { issues: [{
//   message: "must NOT have additional properties",
//   path: ["role"],
//   keyword: "additionalProperties",
// }] }
```

Its type is the output of a Standard Schema:

`main.ts`:

```ts
import type { StandardSchemaV1 } from "dotschema";
import user from "./user.schema.jsonc";

const named = user.pick("name");
type Named = StandardSchemaV1.InferOutput<typeof named>;
//   ^? type Named = {
//          name: string;
//      }
```

A schema that is more than an object of properties, such as a `oneOf`, has neither method.

### Formats

Zod's string formats are checked as Zod checks them, `jwt` among them:

`session.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    "token": { "type": "string", "format": "jwt" }
  }
}
```

`main.ts`:

```ts
import session from "./session.schema.jsonc";

session.validate({ token: "not a token" });
// { issues: [{
//   message: "must match format \"jwt\"",
//   path: ["token"],
//   keyword: "format",
// }] }
```

JSON Schema's own formats, each passing every test of the [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite):

| Format | A value it accepts |
| --- | --- |
| `date-time` | `2026-10-27T09:30:00Z` |
| `date` | `2026-10-27` |
| `time` | `09:30:00+01:00` |
| `duration` | `P1DT12H` |
| `email` | `ada@example.com` |
| `idn-email` | `ada@bücher.example` |
| `hostname` | `api.example.com` |
| `idn-hostname` | `bücher.example` |
| `ipv4` | `192.168.0.1` |
| `ipv6` | `2001:db8::1` |
| `uri` | `https://example.com/a?b=c` |
| `uri-reference` | `../a?b=c` |
| `iri` | `https://bücher.example/straße` |
| `iri-reference` | `/straße` |
| `uri-template` | `https://example.com/{user}` |
| `uuid` | `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` |
| `json-pointer` | `/properties/name` |
| `relative-json-pointer` | `1/name` |
| `regex` | `^[a-z]+$` |

Zod's formats, by the names `z.toJSONSchema` gives them:

| Format | A value it accepts |
| --- | --- |
| `jwt` | `eyJhbGciOiJIUzI1NiJ9.e30.c2ln` |
| `credit_card` | `4242424242424242` |
| `iban` | `GB82WEST12345698765432` |
| `base64` | `aGVsbG8=` |
| `base64url` | `aGVsbG8` |
| `cidrv4` | `192.168.0.0/24` |
| `cidrv6` | `2001:db8::/32` |
| `mac` | `00:1a:2b:3c:4d:5e` |
| `e164` | `+14155550123` |
| `emoji` | `🦕` |
| `nanoid` | `V1StGXR8_Z5jdHi6B-myT` |
| `cuid` | `cjld2cjxh0000qzrmn831i7rn` |
| `cuid2` | `tz4a98xxat96iws9zmbrgj3a` |
| `ulid` | `01ARZ3NDEKTSV4RRFFQ69G5FAV` |
| `xid` | `9m4e2mr0ui3e8a215n4g` |
| `ksuid` | `0ujtsYcgvSTl8PAuAdqWYSMnLOv` |
| `hex` | `deadbeef` |
| `currency_code` | `USD` |
| `lowercase` | `dotschema` |
| `uppercase` | `DOTSCHEMA` |
| `md5_hex`, `md5_base64`, `md5_base64url` | An MD5 digest, in hex, base64 or base64url |
| `sha1_hex`, `sha1_base64`, `sha1_base64url` | A SHA-1 digest |
| `sha256_hex`, `sha256_base64`, `sha256_base64url` | A SHA-256 digest |
| `sha384_hex`, `sha384_base64`, `sha384_base64url` | A SHA-384 digest |
| `sha512_hex`, `sha512_base64`, `sha512_base64url` | A SHA-512 digest |

OpenAPI's formats, and four more of ajv-formats':

| Format | A value it accepts |
| --- | --- |
| `int32` | `2147483647` |
| `int64` | `9007199254740991` |
| `float` | `1.5` |
| `double` | `1.5` |
| `byte` | `aGVsbG8=` |
| `password`, `binary` | Any string: they check nothing |
| `iso-date-time` | `2026-10-27T09:30:00` |
| `iso-time` | `09:30:00` |
| `url` | `https://example.com` |
| `json-pointer-uri-fragment` | `#/properties/name` |

A format no table names is not checked.

## Standard Schema

The module is a [Standard Schema](https://standardschema.dev), so a library that accepts one accepts it:

`main.ts`:

```ts
import user from "./user.schema.jsonc";

user["~standard"].validate(input); // the same function as user.validate
```

It's a Standard JSON Schema too, which gives the schema as one document in draft-07 or 2020-12, with every file it refers to folded in:

`main.ts`:

```ts
import team from "./team.schema.jsonc";

team["~standard"].jsonSchema.input({ target: "draft-2020-12" });
// {
//   $schema: "https://json-schema.org/draft/2020-12/schema",
//   type: "object",
//   properties: {
//     members: { type: "array", items: { $ref: "#/$defs/user" } },
//   },
//   required: ["members"],
//   additionalProperties: false,
//   $defs: { user: { type: "object", properties: { ... }, ... } },
// }
```

## Errors

An invalid schema is a type error, at its place in the file:

`team.schema.jsonc`:

```jsonc
{
  "type": "object",
  "properties": {
    "members": {
      "type": "array",
      "items": { "$ref": "./member.schema.jsonc" }
    }
  },
  "required": ["members"]
}
```

The type check prints it with its line and column:

**Terminal**:

```ansi
$ tsc --noEmit --runExternalCode
team.schema.jsonc:6:27 - error dotschema4: The $ref "./member.schema.jsonc" names a file that does not exist.

6       "items": { "$ref": "./member.schema.jsonc" }
                            ~~~~~~~~~~~~~~~~~~~~~

Found 1 error in team.schema.jsonc:6
```

## Bundles

Each schema's module is built with its validator compiled, so it imports nothing, and a bundle carries the validator and no validator library. A bundled app needs dotschema only to build, as a devDependency.

The opening example's `main.ts`, bundled and minified by esbuild, has two inputs:

**esbuild's analysis**:

```text
  dist/main.mjs         5.9kb  100.0%
   ├ user.schema.jsonc  5.9kb   99.4%
   └ main.ts             35b     0.6%
```

A module carries the code of only the formats it uses, and esbuild leaves out a definition nothing imports.

## Limits

| Limit | Instead |
| --- | --- |
| A `$ref` to a URL (`https:`, `urn:`) is not followed: the type check reports it, and its type is `unknown` | Save the schema as a `.schema.jsonc` file in the project, and `$ref` the file |
| A draft-04 or draft-03 schema is refused | Declare draft-07 or a later draft in `$schema` |
