npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@amritk/adapters

v0.6.2

Published

Convert schemas from external libraries (TypeBox, Zod, Avro, ...) into JSON Schema for mjst.

Readme

@amritk/adapters

Convert schemas authored in TypeBox, Zod, Valibot, Effect, or Apache Avro into Draft 2020-12 JSON Schema — the single input shape the mjst generators understand.

status  version  license  JSON Schema  node  vibe coded


Overview

mjst's generators — parsers, validators, types, docs, test data — all take one input: a Draft 2020-12 JSON Schema. This package lets you author that schema somewhere else instead. It converts a TypeBox, Zod, Valibot, or Effect schema — or an Apache Avro .avsc document — into JSON Schema, then hands the result straight to the generators.

The mjst CLI wires these in behind the --input <format> flag (typebox, zod, valibot, effect, avro). For the library formats the --schema path points at a JS/TS module that exports a schema rather than a .json file; for avro it points at the .avsc document itself, which is read as data and never imported. You can also call the adapters directly — each is a small, pure function.

The library adapters lean on their source library's own JSON Schema exporter (Zod 4's toJSONSchema, @valibot/to-json-schema, Effect's JSONSchema.make; TypeBox schemas are already JSON Schema at runtime) and then normalise the result. Avro is the exception — it has no such exporter, so that conversion is implemented here in full (and needs no dependency, since an Avro schema is itself JSON). Normalisation:

  • strips the dialect marker ($schema) the generators don't need,
  • rewrites constructs JSON Schema can't express — runtime Date, bigint — into the shared x-mjst hint the generators read, and
  • warns and continues on anything genuinely unrepresentable rather than throwing, so one unsupported field never blocks generation (pass { strict: true } to make a lossy construct throw instead).

Installation

npm install --save-dev @amritk/adapters
# or: pnpm add -D / yarn add -D / bun add -d

Peer dependencies

The source libraries are optional peer dependencies — each adapter dynamically imports its library at runtime, so you install only the one(s) you actually use. TypeBox needs no peer dependency at all (its schemas are plain JSON Schema objects; the adapter never imports TypeBox).

| Input format | Install | |:---|:---| | typebox | (nothing — see below) | | zod | zod@>=4, or zod@3 plus zod-to-json-schema@>=3 | | valibot | valibot@>=1 and @valibot/to-json-schema@>=1 | | effect | effect@>=3 | | avro | (nothing — an Avro schema is plain JSON) |

If the required package is missing, the adapter throws a clear, actionable error naming what to install — e.g. "The Zod adapter requires either 'zod' v4+ (for its native toJSONSchema) or the 'zod-to-json-schema' package (a fallback for Zod 3). Neither was found — install one in your project."

[!NOTE] The TypeBox adapter deliberately does not import TypeBox. It works purely on the plain-object shape of the schema (via a JSON round-trip), so TypeBox stays a dependency of your schema module alone, never of mjst.


Usage

Each adapter is a function (source: unknown, options?: { strict?: boolean }) => Promise<JSONSchema> (the TypeBox and Avro adapters are synchronous, but are exposed through getAdapter with the same signature for uniformity — so always await when you go through it). strict: true makes a lossy construct throw instead of widening (Zod, Valibot, Avro). Import them by subpath:

import { avroToJsonSchema } from '@amritk/adapters/avro-to-json-schema'
import { typeboxToJsonSchema } from '@amritk/adapters/typebox-to-json-schema'
import { zodToJsonSchema } from '@amritk/adapters/zod-to-json-schema'
import { valibotToJsonSchema } from '@amritk/adapters/valibot-to-json-schema'
import { effectToJsonSchema } from '@amritk/adapters/effect-to-json-schema'

Or resolve one by name — matching the CLI's --input flag:

import { getAdapter } from '@amritk/adapters/get-adapter'

const adapter = getAdapter('zod') // throws for an unimplemented / unknown format
const jsonSchema = await adapter.toJSONSchema(mySchema)

Adapters receive the already-loaded schema value (an imported module export), not a file path — loading the module is the caller's job, which keeps the adapters pure and trivial to test.

TypeBox

import { Type } from '@sinclair/typebox'
import { typeboxToJsonSchema } from '@amritk/adapters/typebox-to-json-schema'

const User = Type.Object({
  id: Type.Integer(),
  name: Type.String({ minLength: 1 }),
  createdAt: Type.Date(),
})

const jsonSchema = await typeboxToJsonSchema(User)

A TypeBox schema is already a JSON Schema object at runtime — it just carries non-enumerable symbol keys (Kind, Optional, …) for TypeBox's own machinery. A JSON round-trip drops those (and any undefined values), leaving a clean plain schema. The adapter then rewrites TypeBox's extended types into x-mjst hints.

Zod

[!IMPORTANT] Zod 4 preferred. The adapter relies on Zod's native toJSONSchema, which does not exist before Zod 4 (see src/zod-to-json-schema.ts). On Zod 3 it falls back to the optional zod-to-json-schema package — install it alongside Zod 3 — and with neither available you'll get a clear error rather than a silent miss.

import { z } from 'zod'
import { zodToJsonSchema } from '@amritk/adapters/zod-to-json-schema'

const User = z.object({
  id: z.number().int(),
  name: z.string().min(1),
  createdAt: z.date(),
})

const jsonSchema = await zodToJsonSchema(User)

z.date() and z.bigint() have no JSON Schema representation and would make Zod's exporter throw; the adapter runs it with unrepresentable: 'any' and uses the override hook to rescue them into x-mjst hints. It also repairs two Zod 4 quirks along the way: it restores the length bound on fixed tuples (Zod emits a bare, unbounded prefixItems), and it merges an object intersection that Zod emits as an unsatisfiable allOf of additionalProperties: false branches into a single closed object.

Valibot

import * as v from 'valibot'
import { valibotToJsonSchema } from '@amritk/adapters/valibot-to-json-schema'

const User = v.object({
  id: v.pipe(v.number(), v.integer()),
  name: v.pipe(v.string(), v.minLength(1)),
  createdAt: v.date(),
})

const jsonSchema = await valibotToJsonSchema(User)

Requires both valibot and @valibot/to-json-schema. As with Zod, v.date() / v.bigint() are rescued into x-mjst hints via the converter's overrideSchema hook. The converter runs in errorMode: 'ignore' (targeting draft 2020-12), so any other unsupported construct degrades to an open schema; mjst collects those and reports them in one batched [mjst] Valibot adapter: … warning rather than throwing.

Effect

import { Schema } from 'effect'
import { effectToJsonSchema } from '@amritk/adapters/effect-to-json-schema'

const User = Schema.Struct({
  id: Schema.Number,
  name: Schema.String,
  createdAt: Schema.DateFromSelf, // runtime Date — see the caveat below
})

const jsonSchema = await effectToJsonSchema(User)

Requires effect@>=3. A Schema.BigIntFromSelf / Schema.DateFromSelf anywhere in the tree — top level or nested inside a struct, array, or union — is rescued into an x-mjst hint; every subtree JSONSchema.make accepts is taken from it verbatim. Read the encoded-representation caveat before choosing between Schema.Date and Schema.DateFromSelf — it changes whether you get a string or a runtime Date.

Avro

import { readFile } from 'node:fs/promises'
import { avroToJsonSchema } from '@amritk/adapters/avro-to-json-schema'

const avro = JSON.parse(await readFile('user.avsc', 'utf-8'))
const jsonSchema = avroToJsonSchema(avro)

No peer dependency: an Avro schema is a JSON document, so the adapter reads it directly. Every named type — record, enum, fixed — is defined once under its fullname in $defs and referenced by $ref everywhere it appears, so a recursive type stays finite and com.example.User generates a ComExampleUser type rather than an inline shape repeated at each use site.

Pick the encoding you actually mean

Avro is a binary format with a separately specified JSON encoding, and the two readings of "the JSON for this schema" genuinely disagree. The adapter makes you choose:

| encoding | Describes | Unions | bytes | Fields with a default | |:---|:---|:---|:---|:---| | 'json' (default) | the object your application code sees | plain anyOf; ["null", T] collapses to a nullable T | base64 string | optional | | 'avro-json' | the spec's JSON encoding — what travels as application/vnd.apache.avro+json | single-key wrapper objects tagged with the branch's fullname | codepoint-per-byte string | required |

// Generating TypeScript types and parsers? You want the idiomatic shape.
avroToJsonSchema(avro)

// Validating an AsyncAPI examples.payload? You want the wire shape.
avroToJsonSchema(avro, { encoding: 'avro-json' })

The default column is not a stylistic choice. Avro has no optional fields — every declared field is present in the encoding, and a default is only consulted during schema resolution, when reading data written against a different schema. So 'avro-json' marks every field required, because that is what is on the wire, while 'json' treats a defaulted field as optional, because that is the shape application code deals with.

The default value is translated too, not just copied, because Avro writes it in neither encoding exactly. Avro states a union's default as a bare value of the union's first branch, so under 'avro-json' it is wrapped to match the branch tagging the data uses (null stays bare, in both the "null" and {"type": "null"} spellings). Under 'json' a byte default is in the wrong alphabet — Avro writes it latin-1, the idiomatic shape is base64 — so it is dropped rather than mistranslated. Both rules apply at any depth: a union nested inside a record, array, or map default is tagged the same way, and a byte value anywhere inside a default drops the whole thing, since a half-translated default is worse than none. This matters more than a stray annotation would, because @amritk/generate-parsers coerces with default.

What deliberately does not get refined

Two mappings look like omissions and are not:

  • A long gets no bounds. Its range is ±2^63, which no JSON number can represent — a stated maximum would round to 2^63 and be both wrong and unreachable. (A long past 2^53 will not survive JSON.parse intact either, whatever the schema says.) An int is bounded, since ±2^31 lands exactly on a double.
  • Date and time logical types stay integers. Avro encodes timestamp-millis as a long in its JSON encoding as much as in binary, so format: 'date-time' would describe a string that never arrives. Only uuid genuinely narrows its base type, to { type: 'string', format: 'uuid' }.

decimal and duration carry structure JSON Schema cannot express (precision/scale; three unsigned 32-bit ints in 12 bytes), so they degrade to their base type and are reported through the usual widening warning. An unrecognised logicalType falls through to its base type silently, which the Avro spec requires — as does one declared on a base it is not defined for (decimal on a record, say), which is invalid rather than lossy and so is ignored rather than reported. aliases and field order describe how two schemas relate during resolution and have no place in a single document's shape, so they are ignored. Names are validated: one is written straight into a $defs key and the $ref pointing at it, so an illegal name would silently produce a different, broken JSON Pointer rather than an error.


The x-mjst extension

JSON Schema's core vocabulary has no keyword for a runtime Date, a bigint, or a nominal brand. mjst carries those as a vendor extension, x-mjst, that the generators read to emit the right TypeScript type and runtime check:

| x-mjst hint | Generated handling | |:---|:---| | { instanceOf: 'Date' } | typed as Date, checked with instanceof | | { primitive: 'bigint' } | typed as bigint, checked with typeof | | { brand: 'UserId' } | typed as Base & { readonly __brand: 'UserId' }; validates as Base at runtime |

instanceOf / primitive are emitted by the adapters (an adapter maps to the same hint whichever library authored it, so a Date generates identically everywhere). brand is hand-authored — no adapter emits it today — and is purely type-level: the runtime value still validates as its underlying JSON Schema type, but the generated TypeScript type is intersected with a unique brand so a UserId is not interchangeable with a plain string. See Nominal brands below.

Per library, the Date / bigint sources are:

| | DateinstanceOf: 'Date' | bigintprimitive: 'bigint' | |:---|:---|:---| | TypeBox | Type.Date() | Type.BigInt() | | Zod | z.date() | z.bigint() | | Valibot | v.date() | v.bigint() | | Effect | Schema.DateFromSelf | Schema.BigIntFromSelf |

Nominal brands

{ brand: 'Name' } gives a schema a nominal (branded) type without changing what it accepts at runtime. Attach it to any schema and the generated TypeScript intersects the base type with a unique marker:

{ "type": "string", "format": "uuid", "x-mjst": { "brand": "UserId" } }
// generated type: (string & { readonly __brand: 'UserId' })
// runtime check:  still just "a string" (plus the uuid format check)

Because the brand lives only in the type, two ids with different brands are not assignable to each other, so you can't pass an OrderId where a UserId is expected — the same protection Drizzle's .$type<UserId>() gives a column, now carried by the schema. The name must match ^[\w$ -]+$ (it is embedded in a string literal in generated output); an unsafe name is ignored.

Both schema→type paths honour the brand identically:

  • the code generators (@amritk/generate-parsers etc.) emit it into .d.ts;
  • the type-level FromSchema reads it too, so anything typed from a live schema literal — most notably @amritk/api route params / query / body — carries the brand into your handler. Declaring a route param { type: 'string', 'x-mjst': { brand: 'UserId' } } makes params.id a UserId, not a plain string, end-to-end (handler and the derived typed client). Write the schema inline or as const so the literal survives inference.

The brand shape is { readonly __brand: 'UserId' }. If you want it to be the same type as a Drizzle-branded id, define that id to this shape (or brand the Drizzle column to match); mjst aligns a convention, it doesn't reuse Drizzle's own brand symbol.


Lossy constructs & widening warnings

Some source types have no faithful JSON Schema representation and are not rescued into an x-mjst hint. Rather than fail the whole conversion, the adapters widen those to "accept anything" ({}) — which means the generated type is wider than the source schema — and emit a [mjst] warning to stderr so the widening is visible, never silent. Behaviour per library:

  • Zod. These Zod types become "accept anything": symbol, nan, void, undefined, never, map, set, promise, function. When any appear, the adapter logs, e.g.: "[mjst] Zod adapter: function, symbol have no full JSON Schema representation and were widened. The generated type will be wider than the Zod schema."
  • Valibot. The converter runs in errorMode: 'ignore': an unsupported construct degrades to an open schema, and the adapter collects every such construct from the converter's override hooks and reports them in one [mjst] Valibot adapter: … warning of the same shape as Zod's.
  • TypeBox. An extended type string with no mapping (see below) is left unchanged with a warning: "[mjst] TypeBox type '…' has no JSON Schema or x-mjst mapping; leaving it unchanged."
  • Avro. Only the decimal and duration logical types widen, each degrading to its base type: "[mjst] Avro adapter: the decimal logical type (precision/scale) has no full JSON Schema representation and was widened. The generated type will be wider than the Avro schema." Everything else in Avro maps exactly, or is rejected outright — a duplicate name, a reference to an undefined name, or a malformed record/enum/fixed throws rather than converting to something wrong.
  • Effect. Effect does not widen — it is stricter. JSONSchema.make throws on any unrepresentable type, wherever it sits. The adapter catches that and descends structurally, rebuilding the container (struct, array, union, refinement, …) and rescuing every BigIntFromSelf / DateFromSelf leaf it reaches into an x-mjst hint. Only a leaf it has no rescue for (a raw symbol, say) is fatal, and then it throws an actionable message rather than Effect's opaque one: replace the type with a JSON-representable one, or add a jsonSchema annotation to that field.

If any of these matter to your schema, prefer a representable alternative (e.g. model a set as an array) or add the library's own JSON Schema annotation.


Caveats

Extended types (TypeBox)

TypeBox emits non-standard type strings for its runtime classes (e.g. Type.Date() produces { type: 'Date' }). The adapter recognises the seven core JSON Schema types and treats anything else in a type slot as a TypeBox extended type. The map of extended types it understands currently covers only Date and bigint (see src/typebox-to-json-schema.ts:11-19):

| TypeBox extended type | mapped to | |:---|:---| | Date | x-mjst instanceOf: 'Date' | | bigint | x-mjst primitive: 'bigint' |

Any other extended type (Uint8Array, Symbol, Undefined, …) is left untouched with the widening warning above; support is added by extending that map as the generators gain handling for more types.

Effect encodes the wire representation

Effect models a value as a decode/encode pair, and JSONSchema.make describes the encoded (wire) representation — not the runtime type. This is the caveat most likely to surprise you:

  • Schema.Date decodes a Date from a string, so it converts to a string schema — not a runtime Date. The adapter passes this through unchanged, because it accurately reflects what Effect expects on the wire.
  • Only the *FromSelf variants — Schema.DateFromSelf, Schema.BigIntFromSelf — describe the runtime value itself, and those are the ones rescued into x-mjst runtime-type hints (see src/effect-to-json-schema.ts:121-122).

So: want a generated Date? Author Schema.DateFromSelf. Want a string that Effect parses into a Date? Author Schema.Date and expect a string in the generated output. The same distinction applies to Schema.BigInt (→ string) vs Schema.BigIntFromSelf (→ bigint).


Related packages


License

MIT