dotschema
Import JSON Schemas into TypeScript. Fully typed, scary fast.
If you have a JSON Schema like this:
{
"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:
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@betaIf 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@betaTypeScript
Add dotschema to tsconfig.json as a content mapper:
{
"compilerOptions": { /* ... */ },
"contentMappers": [
{ "package": "dotschema", "extensions": [".schema.jsonc"] }
]
}Then type-check with --runExternalCode, which lets tsc run content mappers:
{
"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.tsAdd the preload to bunfig.toml, and under [test] for bun test:
preload = ["dotschema/register"]
[test]
preload = ["dotschema/register"]For Bun's bundler, use the esbuild plugin:
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:
{
"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:
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:
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:
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:
{
"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:
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:
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:
export default {
// ...
module: {
rules: [
{ test: /\.schema\.(jsonc|json5)$/, use: "dotschema/webpack" },
],
},
};Load schema files with dotschema's loader:
export default {
// ...
module: {
rules: [
{ test: /\.schema\.(jsonc|json5)$/, use: "dotschema/webpack" },
],
},
};Add dotschema's Vite plugin beside Cloudflare's:
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:
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 jestBeside 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:
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.
-
Install the TypeScript 7 extension (
TypeScriptTeam.native-preview). -
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" } -
Trust the workspace, and click Allow when the extension asks to use the workspace's TypeScript. Content mappers run in a trusted workspace only.
-
Install the TypeScript 7 extension (
TypeScriptTeam.native-preview). -
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" } -
Trust the workspace, and click Allow when the extension asks to use the workspace's TypeScript. Content mappers run in a trusted workspace only.
-
Install the TypeScript Language Server extension by Zed Industries (search for "tsgo").
-
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 } } } } -
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:
{
// 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:
{
"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:
import from "./team.schema.jsonc";
type Members = team.["members"];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:
{
"type": "object",
"properties": {
"id": { "type": "string", "x-internal": true, "tsType": "AccountId" }
},
"required": ["id"],
"additionalProperties": false
}import from "./account.schema.jsonc";
type Id = account.["id"];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:
import from "./user.schema.jsonc";
const : user. = { : "Ada", : "admin" };
const : user. = { : "Bob", role: "owner" };A /** */ comment above a property is its doc comment, which an editor shows on hover:
{
"type": "object",
"properties": {
/** The name the user signs in with. */
"name": { "type": "string", "minLength": 1 },
"role": { "enum": ["admin", "member"] }
},
"required": ["name"],
"additionalProperties": false
}import from "./user.schema.jsonc";
if (.()) {
.name;}An enum or a const is a literal type:
{
"type": "object",
"properties": {
"status": { "enum": ["draft", "published"] },
"version": { "const": 1 }
},
"required": ["status", "version"],
"additionalProperties": false
}import from "./post.schema.jsonc";
if (.()) {
input;}A schema in additionalProperties types every other key:
{
"type": "object",
"additionalProperties": { "type": "number" }
}import from "./scores.schema.jsonc";
if (.()) {
input;}A prefixItems list is a tuple, its items required up to minItems:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }],
"minItems": 2,
"items": false
}import from "./point.schema.jsonc";
type Point = point.;A oneOf whose variants a const tells apart is a union that narrows:
{
"type": "object",
"oneOf": [
{
"properties": { "kind": { "const": "click" }, "x": { "type": "number" } },
"required": ["kind", "x"]
},
{
"properties": { "kind": { "const": "visit" }, "url": { "type": "string" } },
"required": ["kind", "url"]
}
]
}import from "./event.schema.jsonc";
if (.()) {
if (. === "click") {
.x; } else {
.url; }
}An if that tests a property's listed values narrows the same way:
{
"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"] }
}import from "./payment.schema.jsonc";
if (.() && . === "card") {
.last4;}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:
import from "./user.schema.jsonc";
const = .();
if (.) {
.(.);
// for { name: "" }:
// [{
// message: "must NOT have fewer than 1 characters",
// path: ["name"],
// keyword: "minLength",
// }]
} else {
.value;}.is()
The .is() method is a type guard, which stops at the first failure:
import from "./user.schema.jsonc";
const users = .(.);Definitions
Each entry of $defs is a validator of its own, imported by its key:
{
"type": "object",
"properties": { "home": { "$ref": "#/$defs/address" } },
"$defs": {
"address": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
"additionalProperties": false
}
}
}import { } from "./place.schema.jsonc";
if (.()) {
input;}.pick() and .omit()
The .pick() method gives the schema with only the properties named, and .omit() the schema without them:
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:
import type { } from "dotschema";
import from "./user.schema.jsonc";
const = .("name");
type Named = StandardSchemaV1.<typeof >;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:
{
"type": "object",
"properties": {
"token": { "type": "string", "format": "jwt" }
}
}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:
| 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, so a library that accepts one accepts it:
import from "./user.schema.jsonc";
["~standard"].(); // the same function as user.validateIt'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:
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:
{
"type": "object",
"properties": {
"members": {
"type": "array",
"items": { "$ref": "./member.schema.jsonc" } }
},
"required": ["members"]
}The type check prints it with its line and column:
$ 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:6Bundles
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:
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 |