xschema

TypeScript Adapters

Build TypeScript adapters using @xschemadev/core

This guide covers building adapters in TypeScript using the @xschemadev/core package.

The @xschemadev/core Package

bun add @xschemadev/core
npm install @xschemadev/core
pnpm add @xschemadev/core
yarn add @xschemadev/core

Exports

import {
  // CLI helper
  createAdapterCLI,
  
  // Parser
  parse,
  
  // Types
  type ConvertInput,
  type ConvertResult,
  type JSONSchema,
  type SchemaNode,
  
  // Utilities
  escapeString,
  isPrimitive,
  hasPrototypeProperties,
  PROTOTYPE_PROPERTY_NAMES,
  sortedStringify,
  // ... and more
} from "@xschemadev/core";

Intermediate Representation (IR)

The parse() function converts JSON Schema into a discriminated union called SchemaNode.

Node Kinds

type SchemaNode =
  | StringNode       // { kind: "string", constraints, format? }
  | NumberNode       // { kind: "number", constraints, integer }
  | BooleanNode      // { kind: "boolean" }
  | NullNode         // { kind: "null" }
  | ObjectNode       // { kind: "object", properties, additionalProperties, ... }
  | ArrayNode        // { kind: "array", items, constraints }
  | TupleNode        // { kind: "tuple", prefixItems, restItems, constraints }
  | UnionNode        // { kind: "union", variants } (anyOf)
  | IntersectionNode // { kind: "intersection", schemas } (allOf)
  | OneOfNode        // { kind: "oneOf", schemas }
  | NotNode          // { kind: "not", schema }
  | LiteralNode      // { kind: "literal", value } (const)
  | EnumNode         // { kind: "enum", values }
  | AnyNode          // { kind: "any" } (true schema, {})
  | NeverNode        // { kind: "never" } (false schema, not: {})
  | ConditionalNode  // { kind: "conditional", if, then?, else? }
  | TypeGuardedNode  // { kind: "typeGuarded", guards }
  | NullableNode;    // { kind: "nullable", inner } (OpenAPI nullable)

Constraint Types

interface StringConstraints {
  minLength?: number;
  maxLength?: number;
  pattern?: string;
}

interface NumberConstraints {
  minimum?: number;
  maximum?: number;
  exclusiveMinimum?: number;
  exclusiveMaximum?: number;
  multipleOf?: number;
}

interface ArrayConstraints {
  minItems?: number;
  maxItems?: number;
  uniqueItems?: boolean;
  contains?: ContainsConstraint;
}

Building an Adapter

Create the convert function

This is your main logic - parse the schema to IR and render it:

// src/index.ts
import { 
  parse, 
  type ConvertInput, 
  type ConvertResult,
  type JSONSchema 
} from "@xschemadev/core";
import { render } from "./renderer.js";

export function convert(input: ConvertInput): ConvertResult {
  const { namespace, id, varName, schema } = input;
  
  // Parse JSON Schema -> IR
  const ir = parse(schema as JSONSchema);
  
  // Render IR -> code string
  const schemaCode = render(ir);
  
  return {
    namespace,
    id,
    varName,
    imports: ['import { z } from "zod"'],
    schema: schemaCode,
    type: `z.infer<typeof ${varName}>`,
  };
}

Create the renderer

Implement a switch on node.kind to handle each IR type:

// src/renderer.ts
import type { SchemaNode } from "@xschemadev/core";

export function render(node: SchemaNode): string {
  switch (node.kind) {
    case "string":
      return renderString(node);
    case "number":
      return renderNumber(node);
    case "boolean":
      return "z.boolean()";
    case "null":
      return "z.null()";
    case "object":
      return renderObject(node);
    case "array":
      return renderArray(node);
    // ... handle all node kinds
    default:
      // TypeScript exhaustive check
      const _exhaustive: never = node;
      throw new Error(`Unhandled: ${(node as any).kind}`);
  }
}

Always handle ALL node kinds. TypeScript's exhaustive check (const _: never = node) catches missing cases at compile time.

Create the CLI entry point

Use createAdapterCLI() to wire up stdin/stdout:

// src/cli.ts
#!/usr/bin/env node
import { createAdapterCLI } from "@xschemadev/core";
import { convert } from "./index.js";

createAdapterCLI(convert);

Configure package.json

{
  "name": "@xschemadev/my-adapter",
  "bin": {
    "xschema-my-adapter": "./dist/cli.js"
  },
  "peerDependencies": {
    "my-validation-library": "^1.0.0"
  }
}

The bin name must match the adapter name used in xschema configs.

Key Utilities

escapeString(str)

Escapes a string for use in generated code:

escapeString("hello\nworld") // '"hello\\nworld"'

isPrimitive(value)

Checks if a value is a JSON primitive (string, number, boolean, null):

isPrimitive("hello")  // true
isPrimitive({ a: 1 }) // false

hasPrototypeProperties(keys)

Checks if property names include dangerous prototype properties:

hasPrototypeProperties(["name", "__proto__"]) // true
hasPrototypeProperties(["name", "email"])     // false

Properties like __proto__, constructor, and toString exist on Object.prototype. Some validation libraries (like Valibot) crash when validating these. Always check with hasPrototypeProperties() and handle specially if needed.

PROTOTYPE_PROPERTY_NAMES

Set of all prototype property names to watch for:

PROTOTYPE_PROPERTY_NAMES.has("__proto__")     // true
PROTOTYPE_PROPERTY_NAMES.has("constructor")   // true
PROTOTYPE_PROPERTY_NAMES.has("normalProp")    // false

sortedStringify(value)

JSON.stringify with sorted object keys (for stable equality):

sortedStringify({ b: 2, a: 1 }) // '{"a":1,"b":2}'

Compliance Workflow

Build your adapter

bun run build
npm run build
pnpm run build
yarn build

Run initial compliance check

Start with a single keyword to see immediate results:

xschema compliance --adapter-path . --keyword type

Iterate on failing tests

Run with --verbose to see detailed error messages:

xschema compliance --adapter-path . --verbose

Generate full report

Once most tests pass, generate the complete report:

xschema compliance --adapter-path . --dev-report

This creates:

  • compliance/results/{draft}.json - detailed results per draft
  • compliance/REPORT.md - human-readable summary

Example: Zod Adapter

The Zod adapter is a good reference implementation:

cli.ts
index.ts
renderer.ts
package.json
  • cli.ts - CLI entry point (~5 lines)
  • index.ts - convert() function (~20 lines)
  • renderer.ts - IR to Zod code (~900 lines)

See the full source at typescript/packages/adapters/zod/src/.

On this page