dotschema

dotschema

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

If you have a JSON Schema like this:

user.schema.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
import  from "./user.schema.jsonc";

const  = await request.json();

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

It's powered by TypeScript's content mappers (introduced in TypeScript 7.1), which let a package give tsc the types of a file it imports. The validator is compiled by ajv 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.
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:

npm install dotschema
npm install -D typescript@beta

TypeScript

Add dotschema to tsconfig.json as a content mapper:

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

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

package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit --runExternalCode"
  }
}

Plugins

Install the plugin for your runtime or framework of choice:

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

node --import dotschema/register main.ts

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

bunfig.toml
preload = ["dotschema/register"]

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

For Bun's bundler, use the esbuild plugin:

build.ts
import dotschema from "dotschema/esbuild";

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

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

nub.jsonc
{
  "preload": ["dotschema/register"]
}

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
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
import dotschema from "dotschema/vite";

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

Wrap your Next.js config with withDotschema:

next.config.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
{
  "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
export default withMotherload(withDotschema(nextConfig));

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

build.mjs
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",
});

Load schema files with dotschema's loader:

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

Load schema files with dotschema's loader:

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

Add dotschema's Vite plugin beside Cloudflare's:

vite.config.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.

Add dotschema's transformer to your Jest config:

jest.config.js
export default {
  transform: { "\\.schema\\.(jsonc|json5)$": "dotschema/jest" },
  extensionsToTreatAsEsm: [".jsonc", ".json5"],
};
# 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
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.

  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.

    .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.

  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.

    .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.

  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:

    .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
{
  // who can sign in
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "role": { "enum": ["admin", "member"] },
  },
  "required": ["name"],
  "additionalProperties": false,
}

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

{
  "$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
{
  "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
import  from "./team.schema.jsonc";

type Members = team.["members"];
type Members = User[]

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

{ "$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
{
  "type": "object",
  "properties": {
    "id": { "type": "string", "x-internal": true, "tsType": "AccountId" }
  },
  "required": ["id"],
  "additionalProperties": false
}
main.ts
import  from "./account.schema.jsonc";

type Id = account.["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
import  from "./user.schema.jsonc";

const : user. = { : "Ada", : "admin" };
const : user. = { : "Bob", role: "owner" };
Type '"owner"' is not assignable to type '"admin" | "member" | undefined'. ts(2322)

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

user.schema.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
import  from "./user.schema.jsonc";

if (.()) {
  .name;
name: string

The name the user signs in with.

}

An enum or a const is a literal type:

post.schema.jsonc
{
  "type": "object",
  "properties": {
    "status": { "enum": ["draft", "published"] },
    "version": { "const": 1 }
  },
  "required": ["status", "version"],
  "additionalProperties": false
}
main.ts
import  from "./post.schema.jsonc";

if (.()) {
  input;
const input: {
    status: "draft" | "published";
    version: 1;
}
}

A schema in additionalProperties types every other key:

scores.schema.jsonc
{
  "type": "object",
  "additionalProperties": { "type": "number" }
}
main.ts
import  from "./scores.schema.jsonc";

if (.()) {
  input;
const input: {
    [key: string]: number;
}
}

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

point.schema.jsonc
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "array",
  "prefixItems": [{ "type": "number" }, { "type": "number" }],
  "minItems": 2,
  "items": false
}
main.ts
import  from "./point.schema.jsonc";

type Point = point.;
type Point = [number, number]

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

event.schema.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
import  from "./event.schema.jsonc";

if (.()) {
  if (. === "click") {
    .x;
x: number
} else { .url;
url: string
} }

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

payment.schema.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
import  from "./payment.schema.jsonc";

if (.() && . === "card") {
  .last4;
last4: string
}

Validating

The validator is compiled by ajv 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
import  from "./user.schema.jsonc";

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

.is()

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

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

const users = .(.);
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
{
  "type": "object",
  "properties": { "home": { "$ref": "#/$defs/address" } },
  "$defs": {
    "address": {
      "type": "object",
      "properties": { "city": { "type": "string" } },
      "required": ["city"],
      "additionalProperties": false
    }
  }
}
main.ts
import {  } from "./place.schema.jsonc";

if (.()) {
  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
import  from "./user.schema.jsonc";

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

Its type is the output of a Standard Schema:

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

const  = .("name");
type Named = StandardSchemaV1.<typeof >;
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
{
  "type": "object",
  "properties": {
    "token": { "type": "string", "format": "jwt" }
  }
}
main.ts
import  from "./session.schema.jsonc";

.({ : "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:

FormatA value it accepts
date-time2026-10-27T09:30:00Z
date2026-10-27
time09:30:00+01:00
durationP1DT12H
emailada@example.com
idn-emailada@bücher.example
hostnameapi.example.com
idn-hostnamebücher.example
ipv4192.168.0.1
ipv62001:db8::1
urihttps://example.com/a?b=c
uri-reference../a?b=c
irihttps://bücher.example/straße
iri-reference/straße
uri-templatehttps://example.com/{user}
uuidf81d4fae-7dec-11d0-a765-00a0c91e6bf6
json-pointer/properties/name
relative-json-pointer1/name
regex^[a-z]+$

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

FormatA value it accepts
jwteyJhbGciOiJIUzI1NiJ9.e30.c2ln
credit_card4242424242424242
ibanGB82WEST12345698765432
base64aGVsbG8=
base64urlaGVsbG8
cidrv4192.168.0.0/24
cidrv62001:db8::/32
mac00:1a:2b:3c:4d:5e
e164+14155550123
emoji🦕
nanoidV1StGXR8_Z5jdHi6B-myT
cuidcjld2cjxh0000qzrmn831i7rn
cuid2tz4a98xxat96iws9zmbrgj3a
ulid01ARZ3NDEKTSV4RRFFQ69G5FAV
xid9m4e2mr0ui3e8a215n4g
ksuid0ujtsYcgvSTl8PAuAdqWYSMnLOv
hexdeadbeef
currency_codeUSD
lowercasedotschema
uppercaseDOTSCHEMA
md5_hex, md5_base64, md5_base64urlAn MD5 digest, in hex, base64 or base64url
sha1_hex, sha1_base64, sha1_base64urlA SHA-1 digest
sha256_hex, sha256_base64, sha256_base64urlA SHA-256 digest
sha384_hex, sha384_base64, sha384_base64urlA SHA-384 digest
sha512_hex, sha512_base64, sha512_base64urlA SHA-512 digest

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

FormatA value it accepts
int322147483647
int649007199254740991
float1.5
double1.5
byteaGVsbG8=
password, binaryAny string: they check nothing
iso-date-time2026-10-27T09:30:00
iso-time09:30:00
urlhttps://example.com
json-pointer-uri-fragment#/properties/name

A format no table names is not checked.

Standard Schema

The module is a Standard Schema, so a library that accepts one accepts it:

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

["~standard"].(); // 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
import  from "./team.schema.jsonc";

["~standard"]..({ : "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
{
  "type": "object",
  "properties": {
    "members": {
      "type": "array",
      "items": { "$ref": "./member.schema.jsonc" }
The $ref "./member.schema.jsonc" names a file that does not exist. dotschema(4)
} }, "required": ["members"] }

The type check prints it with its line and column:

Terminal
$ 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
  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

LimitInstead
A $ref to a URL (https:, urn:) is not followed: the type check reports it, and its type is unknownSave the schema as a .schema.jsonc file in the project, and $ref the file
A draft-04 or draft-03 schema is refusedDeclare draft-07 or a later draft in $schema
Released under the MIT License. Copyright © 2026 Colin McDonnell.

On this page

GitHub