Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@amritk/adapters

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@amritk/adapters - npm Package Compare versions

Comparing version
0.2.8
to
0.2.9
+0
-1
dist/adapter.d.ts

@@ -23,2 +23,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

};
//# sourceMappingURL=adapter.d.ts.map

@@ -12,2 +12,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const effectToJsonSchema: (source: unknown) => Promise<JSONSchema>;
//# sourceMappingURL=effect-to-json-schema.d.ts.map

@@ -12,2 +12,1 @@ import type { Adapter } from './adapter.js';

export declare const getAdapter: (format: SourceFormat) => Adapter;
//# sourceMappingURL=get-adapter.d.ts.map

@@ -12,2 +12,1 @@ /**

export type SourceFormat = 'json' | 'typebox' | 'zod' | 'valibot' | 'effect';
//# sourceMappingURL=source-format.d.ts.map

@@ -14,2 +14,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const typeboxToJsonSchema: (source: unknown) => JSONSchema;
//# sourceMappingURL=typebox-to-json-schema.d.ts.map

@@ -14,2 +14,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const valibotToJsonSchema: (source: unknown) => Promise<JSONSchema>;
//# sourceMappingURL=valibot-to-json-schema.d.ts.map

@@ -13,2 +13,1 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';

export declare const zodToJsonSchema: (source: unknown) => Promise<JSONSchema>;
//# sourceMappingURL=zod-to-json-schema.d.ts.map
+3
-11
{
"name": "@amritk/adapters",
"version": "0.2.8",
"version": "0.2.9",
"description": "Convert schemas from external libraries (TypeBox, Zod, ...) into JSON Schema for mjst.",

@@ -28,4 +28,3 @@ "type": "module",

"files": [
"dist",
"src"
"dist"
],

@@ -42,3 +41,3 @@ "publishConfig": {

"json-schema-typed": "^8.0.1",
"@amritk/helpers": "0.8.0"
"@amritk/helpers": "0.9.0"
},

@@ -48,3 +47,2 @@ "exports": {

"./adapter": {
"development": "./src/adapter.ts",
"default": "./dist/adapter.js",

@@ -54,3 +52,2 @@ "types": "./dist/adapter.d.ts"

"./source-format": {
"development": "./src/source-format.ts",
"default": "./dist/source-format.js",

@@ -60,3 +57,2 @@ "types": "./dist/source-format.d.ts"

"./get-adapter": {
"development": "./src/get-adapter.ts",
"default": "./dist/get-adapter.js",

@@ -66,3 +62,2 @@ "types": "./dist/get-adapter.d.ts"

"./typebox-to-json-schema": {
"development": "./src/typebox-to-json-schema.ts",
"default": "./dist/typebox-to-json-schema.js",

@@ -72,3 +67,2 @@ "types": "./dist/typebox-to-json-schema.d.ts"

"./zod-to-json-schema": {
"development": "./src/zod-to-json-schema.ts",
"default": "./dist/zod-to-json-schema.js",

@@ -78,3 +72,2 @@ "types": "./dist/zod-to-json-schema.d.ts"

"./valibot-to-json-schema": {
"development": "./src/valibot-to-json-schema.ts",
"default": "./dist/valibot-to-json-schema.js",

@@ -84,3 +77,2 @@ "types": "./dist/valibot-to-json-schema.d.ts"

"./effect-to-json-schema": {
"development": "./src/effect-to-json-schema.ts",
"default": "./dist/effect-to-json-schema.js",

@@ -87,0 +79,0 @@ "types": "./dist/effect-to-json-schema.d.ts"

{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD;;;;;;;;;GASG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;CAC7E,CAAA"}
{"version":3,"file":"effect-to-json-schema.d.ts","sourceRoot":"","sources":["../src/effect-to-json-schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA4BjE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,WAAkB,OAAO,KAAG,OAAO,CAAC,UAAU,CAmB5E,CAAA"}
{"version":3,"file":"get-adapter.d.ts","sourceRoot":"","sources":["../src/get-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAyBnD;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,WAAY,YAAY,KAAG,OAgBjD,CAAA"}
{"version":3,"file":"source-format.d.ts","sourceRoot":"","sources":["../src/source-format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,SAAS,GAAG,KAAK,GAAG,SAAS,GAAG,QAAQ,CAAA"}
{"version":3,"file":"typebox-to-json-schema.d.ts","sourceRoot":"","sources":["../src/typebox-to-json-schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAwDjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,WAAY,OAAO,KAAG,UAUrD,CAAA"}
{"version":3,"file":"valibot-to-json-schema.d.ts","sourceRoot":"","sources":["../src/valibot-to-json-schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAuCjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,WAAkB,OAAO,KAAG,OAAO,CAAC,UAAU,CA0B7E,CAAA"}
{"version":3,"file":"zod-to-json-schema.d.ts","sourceRoot":"","sources":["../src/zod-to-json-schema.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA4CjE;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,WAAkB,OAAO,KAAG,OAAO,CAAC,UAAU,CA6CzE,CAAA"}
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
import type { SourceFormat } from './source-format'
/**
* Converts a schema authored in some external library into a Draft 2020-12
* JSON Schema, which is the single input shape the mjst generators understand.
*
* Adapters receive the already-loaded schema value (an imported module export),
* not a file path. Loading the module is the caller's job so adapters stay pure
* and trivial to unit test. When a source construct cannot be represented in
* JSON Schema, adapters should warn and continue on a best-effort basis rather
* than throw, so generation is not blocked by a single unsupported field.
*/
export type Adapter = {
/** The source format this adapter handles, matching the `--input` CLI flag. */
readonly format: SourceFormat
/**
* Convert a loaded source schema value into a JSON Schema. May be async:
* some adapters (e.g. Zod) dynamically import their source library to perform
* the conversion, so callers should always `await` the result.
*/
readonly toJSONSchema: (source: unknown) => JSONSchema | Promise<JSONSchema>
}
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
import { Schema } from 'effect'
import { describe, expect, it } from 'vitest'
import { effectToJsonSchema } from './effect-to-json-schema'
describe('effectToJsonSchema', () => {
it('converts a struct with required and optional fields', async () => {
const schema = Schema.Struct({
name: Schema.String,
age: Schema.optional(Schema.Number),
})
const result = await effectToJsonSchema(schema)
expect(result).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
})
expect((result as { required: string[] }).required).not.toContain('age')
})
it('strips the $schema dialect marker', async () => {
const result = await effectToJsonSchema(Schema.Struct({ name: Schema.String }))
expect(result).not.toHaveProperty('$schema')
})
it('converts arrays and nested structs', async () => {
const schema = Schema.Struct({
tags: Schema.Array(Schema.String),
profile: Schema.Struct({ id: Schema.String }),
})
const result = await effectToJsonSchema(schema)
expect(result).toMatchObject({
properties: {
tags: { type: 'array', items: { type: 'string' } },
profile: { type: 'object', properties: { id: { type: 'string' } } },
},
})
})
it('represents Schema.Date as its encoded string form', async () => {
// Effect models Schema.Date as a string-to-Date decode, so the JSON Schema
// (the wire representation) is a string referenced via $defs.
const result = await effectToJsonSchema(Schema.Struct({ when: Schema.Date }))
const json = JSON.stringify(result)
expect(json).toContain('"when"')
expect(json).toContain('string')
})
it('throws a helpful error for non-object input', async () => {
await expect(effectToJsonSchema(null)).rejects.toThrow(/expected an Effect Schema but received null/)
await expect(effectToJsonSchema('nope')).rejects.toThrow(/received string/)
})
it('round-trips a plain struct through the type generator', async () => {
const schema = Schema.Struct({
id: Schema.String,
score: Schema.optional(Schema.Number),
})
const typeDef = generateTypeDefinition(await effectToJsonSchema(schema), 'Event')
expect(typeDef).toContain('id: string;')
expect(typeDef).toContain('score?: number;')
})
})
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
// Minimal structural view of `effect`'s `JSONSchema.make`, loaded at runtime so
// `effect` stays an optional peer dependency.
type Make = (schema: unknown) => Record<string, unknown>
/**
* Resolves `effect`'s `JSONSchema.make` from a runtime import, throwing a clear
* error when it is missing.
*/
const loadMake = async (): Promise<Make> => {
let mod: Record<string, unknown>
try {
mod = (await import('effect')) as Record<string, unknown>
} catch {
throw new Error("The Effect adapter requires 'effect' to be installed in your project.")
}
const jsonSchema = mod['JSONSchema'] as Record<string, unknown> | undefined
const make = jsonSchema?.['make']
if (typeof make !== 'function') {
throw new Error("Effect's 'JSONSchema.make' was not found. The Effect adapter requires effect v3 or later.")
}
return make as Make
}
/**
* Converts an Effect `Schema` into a JSON Schema via `JSONSchema.make`.
*
* Effect models values as a decode/encode pair, so `JSONSchema.make` describes
* the *encoded* (wire) representation. For example `Schema.Date` decodes from a
* string, so it converts to a string schema rather than a runtime `Date`. We
* pass that representation straight through — it accurately reflects what Effect
* expects on the wire — only stripping the dialect marker.
*/
export const effectToJsonSchema = async (source: unknown): Promise<JSONSchema> => {
// Effect `Schema` values are callable, so they report as `function`, not `object`.
if ((typeof source !== 'object' && typeof source !== 'function') || source === null) {
const received = source === null ? 'null' : typeof source
throw new Error(`Effect adapter expected an Effect Schema but received ${received}.`)
}
const make = await loadMake()
let json: Record<string, unknown>
try {
json = make(source)
} catch (error) {
throw new Error(`Effect adapter failed to convert the schema. Is it a valid Effect Schema?\n${String(error)}`)
}
delete json['$schema']
return json as JSONSchema
}
import { describe, expect, it } from 'vitest'
import * as zodModule from 'zod'
import { getAdapter } from './get-adapter'
const z = ((zodModule as Record<string, unknown>)['z'] ??
(zodModule as Record<string, unknown>)['default'] ??
zodModule) as typeof import('zod')['z']
describe('getAdapter', () => {
it('returns the TypeBox adapter', () => {
const adapter = getAdapter('typebox')
expect(adapter.format).toBe('typebox')
expect(adapter.toJSONSchema({ type: 'string' })).toEqual({ type: 'string' })
})
it('returns the Zod adapter, which converts asynchronously', async () => {
const adapter = getAdapter('zod')
expect(adapter.format).toBe('zod')
const result = await adapter.toJSONSchema(z.object({ name: z.string() }))
expect(result).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } })
})
it('returns the Valibot adapter', () => {
expect(getAdapter('valibot').format).toBe('valibot')
})
it('returns the Effect adapter', () => {
expect(getAdapter('effect').format).toBe('effect')
})
it('throws an actionable error for the json format (handled directly by the CLI)', () => {
expect(() => getAdapter('json')).toThrow(/No adapter is available for input format 'json'/)
})
})
import type { Adapter } from './adapter'
import { effectToJsonSchema } from './effect-to-json-schema'
import type { SourceFormat } from './source-format'
import { typeboxToJsonSchema } from './typebox-to-json-schema'
import { valibotToJsonSchema } from './valibot-to-json-schema'
import { zodToJsonSchema } from './zod-to-json-schema'
const typeboxAdapter: Adapter = {
format: 'typebox',
toJSONSchema: typeboxToJsonSchema,
}
const zodAdapter: Adapter = {
format: 'zod',
toJSONSchema: zodToJsonSchema,
}
const valibotAdapter: Adapter = {
format: 'valibot',
toJSONSchema: valibotToJsonSchema,
}
const effectAdapter: Adapter = {
format: 'effect',
toJSONSchema: effectToJsonSchema,
}
/**
* Resolves the adapter for a non-JSON source format.
*
* Only formats with an implemented adapter resolve. The `'json'` format never
* reaches here — the CLI reads JSON Schema files directly without an adapter —
* and formats that are named but not yet built throw a clear, actionable error
* so the CLI can fail fast with guidance instead of a cryptic crash.
*/
export const getAdapter = (format: SourceFormat): Adapter => {
switch (format) {
case 'typebox':
return typeboxAdapter
case 'zod':
return zodAdapter
case 'valibot':
return valibotAdapter
case 'effect':
return effectAdapter
default:
throw new Error(
`No adapter is available for input format '${format}'. ` +
`Supported: 'typebox', 'zod', 'valibot', 'effect'. Use --input json for plain JSON Schema files.`,
)
}
}
/**
* The schema authoring formats mjst can ingest.
*
* `'json'` is the built-in default: a plain JSON Schema file read from disk and
* handed straight to the generators. The others name external libraries whose
* schemas are first converted to JSON Schema by a matching adapter.
*
* A format may appear here before its adapter exists — `getAdapter` is the
* source of truth for what is actually implemented today.
*/
export type SourceFormat = 'json' | 'typebox' | 'zod' | 'valibot' | 'effect'
import { Type } from '@sinclair/typebox'
import { describe, expect, it } from 'vitest'
import { typeboxToJsonSchema } from './typebox-to-json-schema'
describe('typeboxToJsonSchema', () => {
it('round-trips a real TypeBox schema, mapping Date and bigint', () => {
const schema = Type.Object({
id: Type.String(),
when: Type.Date(),
balance: Type.BigInt(),
nickname: Type.Optional(Type.String()),
})
expect(typeboxToJsonSchema(schema)).toEqual({
type: 'object',
required: ['id', 'when', 'balance'],
properties: {
id: { type: 'string' },
when: { 'x-mjst': { instanceOf: 'Date' } },
balance: { 'x-mjst': { primitive: 'bigint' } },
nickname: { type: 'string' },
},
})
})
it('strips TypeBox symbol keys and returns plain JSON Schema', () => {
// Mimic a TypeBox object schema: a plain JSON-Schema-shaped object that also
// carries internal symbol keys. The symbols must not survive conversion.
const Kind = Symbol.for('TypeBox.Kind')
const typeboxSchema = {
[Kind]: 'Object',
type: 'object',
properties: {
name: { [Kind]: 'String', type: 'string' },
age: { [Kind]: 'Number', type: 'number' },
},
required: ['name'],
}
const result = typeboxToJsonSchema(typeboxSchema)
expect(result).toEqual({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
})
expect(Object.getOwnPropertySymbols(result)).toHaveLength(0)
})
it('preserves nested structures and arrays', () => {
const schema = {
type: 'object',
properties: {
tags: { type: 'array', items: { type: 'string' } },
meta: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
},
}
expect(typeboxToJsonSchema(schema)).toEqual(schema)
})
it('rewrites TypeBox Date into an x-mjst instanceOf hint', () => {
const schema = {
type: 'object',
properties: {
createdAt: { type: 'Date' },
name: { type: 'string' },
},
required: ['createdAt'],
}
expect(typeboxToJsonSchema(schema)).toEqual({
type: 'object',
properties: {
createdAt: { 'x-mjst': { instanceOf: 'Date' } },
name: { type: 'string' },
},
required: ['createdAt'],
})
})
it('leaves unmapped extended types unchanged', () => {
const schema = { type: 'object', properties: { buf: { type: 'Uint8Array' } } }
expect(typeboxToJsonSchema(schema)).toEqual(schema)
})
it('rewrites TypeBox bigint into an x-mjst primitive hint', () => {
const schema = {
type: 'object',
properties: { balance: { type: 'bigint' }, name: { type: 'string' } },
required: ['balance'],
}
expect(typeboxToJsonSchema(schema)).toEqual({
type: 'object',
properties: {
balance: { 'x-mjst': { primitive: 'bigint' } },
name: { type: 'string' },
},
required: ['balance'],
})
})
it('preserves a hand-authored x-mjst brand keyword', () => {
const schema = {
type: 'object',
properties: { id: { type: 'string', 'x-mjst': { brand: 'UserId' } } },
}
expect(typeboxToJsonSchema(schema)).toEqual(schema)
})
it('throws a helpful error for non-object input', () => {
expect(() => typeboxToJsonSchema(null)).toThrow(/expected a schema object but received null/)
expect(() => typeboxToJsonSchema('nope')).toThrow(/received string/)
})
})
import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension'
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
// The seven core JSON Schema 2020-12 types. Anything else in a `type` slot is a
// TypeBox extended type with no native JSON Schema equivalent.
const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null'])
// Maps a TypeBox extended `type` string to the runtime class an `x-mjst`
// instanceOf hint should reference. Add entries here as more extended types
// gain generator support.
const EXTENDED_TYPE_TO_INSTANCE: Record<string, string> = {
Date: 'Date',
}
// Maps a TypeBox extended `type` string to a non-JSON `x-mjst` primitive hint
// (e.g. Type.BigInt() emits `{ type: 'bigint' }`).
const EXTENDED_TYPE_TO_PRIMITIVE: Record<string, string> = {
bigint: 'bigint',
}
/**
* Recursively rewrites TypeBox extended types into an `x-mjst` instanceOf hint.
*
* TypeBox emits non-standard `type` strings for runtime classes (e.g.
* `Type.Date()` produces `{ type: 'Date' }`). We drop the bogus `type` and
* record the class under `x-mjst` so the generators can emit the right
* TypeScript type and `instanceof` checks. Extended types we do not yet map are
* left untouched with a warning, preserving a permissive best-effort fallback.
*/
const rewriteExtendedTypes = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(rewriteExtendedTypes)
if (typeof value !== 'object' || value === null) return value
const source = value as Record<string, unknown>
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(source)) {
result[key] = rewriteExtendedTypes(val)
}
const type = result['type']
if (typeof type === 'string' && !JSON_SCHEMA_TYPES.has(type)) {
const primitive = EXTENDED_TYPE_TO_PRIMITIVE[type]
const instanceOf = EXTENDED_TYPE_TO_INSTANCE[type]
if (primitive) {
delete result['type']
result[MJST_EXTENSION_KEY] = { primitive }
} else if (instanceOf) {
delete result['type']
result[MJST_EXTENSION_KEY] = { instanceOf }
} else {
console.warn(`[mjst] TypeBox type '${type}' has no JSON Schema or x-mjst mapping; leaving it unchanged.`)
}
}
return result
}
/**
* TypeBox schemas are already JSON Schema objects at runtime, but they carry
* non-enumerable symbol keys (`Kind`, `Optional`, ...) that TypeBox uses for its
* own type machinery. A JSON round-trip drops those symbols (and any `undefined`
* values), leaving a clean, plain JSON Schema. We then rewrite TypeBox's
* extended types (Date, ...) into `x-mjst` hints the generators understand.
*
* We deliberately do not import TypeBox here: the conversion only touches the
* plain-object shape, so TypeBox stays an optional peer dependency used solely
* by the consumer's schema module, never by mjst itself.
*/
export const typeboxToJsonSchema = (source: unknown): JSONSchema => {
if (typeof source !== 'object' || source === null) {
const received = source === null ? 'null' : typeof source
throw new Error(`TypeBox adapter expected a schema object but received ${received}.`)
}
// Boundary cast: JSON.parse yields `unknown`, and the round-tripped, rewritten
// TypeBox schema is a valid JSON Schema by construction.
const sanitized: unknown = JSON.parse(JSON.stringify(source))
return rewriteExtendedTypes(sanitized) as JSONSchema
}
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
import * as v from 'valibot'
import { describe, expect, it } from 'vitest'
import { valibotToJsonSchema } from './valibot-to-json-schema'
describe('valibotToJsonSchema', () => {
it('converts an object with required and optional fields', async () => {
const schema = v.object({
name: v.string(),
age: v.optional(v.number()),
})
const result = await valibotToJsonSchema(schema)
expect(result).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
})
expect((result as { required: string[] }).required).not.toContain('age')
})
it('converts picklists to a string schema with an enum list', async () => {
const result = await valibotToJsonSchema(v.object({ role: v.picklist(['admin', 'user']) }))
expect(result).toMatchObject({
properties: { role: { type: 'string', enum: ['admin', 'user'] } },
})
})
it('converts arrays and nested objects', async () => {
const schema = v.object({
tags: v.array(v.string()),
profile: v.object({ id: v.string() }),
})
const result = await valibotToJsonSchema(schema)
expect(result).toMatchObject({
properties: {
tags: { type: 'array', items: { type: 'string' } },
profile: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
})
})
it('strips the $schema dialect marker', async () => {
const result = await valibotToJsonSchema(v.object({ name: v.string() }))
expect(result).not.toHaveProperty('$schema')
})
it('maps v.date() to an x-mjst instanceOf Date hint', async () => {
const result = await valibotToJsonSchema(v.object({ when: v.date(), name: v.string() }))
expect(result).toMatchObject({
properties: {
when: { 'x-mjst': { instanceOf: 'Date' } },
name: { type: 'string' },
},
})
expect((result as { properties: { when: Record<string, unknown> } }).properties.when).not.toHaveProperty('type')
})
it('maps v.bigint() to an x-mjst primitive bigint hint', async () => {
const result = await valibotToJsonSchema(v.object({ balance: v.bigint(), name: v.string() }))
expect(result).toMatchObject({
properties: {
balance: { 'x-mjst': { primitive: 'bigint' } },
name: { type: 'string' },
},
})
expect((result as { properties: { balance: Record<string, unknown> } }).properties.balance).not.toHaveProperty(
'type',
)
})
it('throws a helpful error for non-object input', async () => {
await expect(valibotToJsonSchema(null)).rejects.toThrow(/expected a Valibot schema but received null/)
await expect(valibotToJsonSchema(42)).rejects.toThrow(/received number/)
})
it('round-trips through the type generator, including Date fields', async () => {
const schema = v.object({
id: v.string(),
createdAt: v.date(),
score: v.optional(v.number()),
})
const typeDef = generateTypeDefinition(await valibotToJsonSchema(schema), 'Event')
expect(typeDef).toContain('id: string;')
expect(typeDef).toContain('createdAt: Date;')
expect(typeDef).toContain('score?: number;')
})
})
import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension'
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
// Minimal structural view of `@valibot/to-json-schema`, so the adapter does not
// hard-depend on its types — the converter is loaded at runtime as an optional
// peer dependency.
type ValibotOverrideContext = { readonly valibotSchema?: { readonly type?: string } }
type ValibotConfig = {
readonly errorMode?: 'throw' | 'warn' | 'ignore'
readonly overrideSchema?: (ctx: ValibotOverrideContext) => Record<string, unknown> | undefined
}
type ToJsonSchema = (schema: unknown, config?: ValibotConfig) => Record<string, unknown>
/**
* Resolves `@valibot/to-json-schema`'s `toJsonSchema` from a runtime import,
* throwing a clear error when the converter is missing.
*/
const loadToJsonSchema = async (): Promise<ToJsonSchema> => {
let mod: Record<string, unknown>
try {
mod = (await import('@valibot/to-json-schema')) as Record<string, unknown>
} catch {
throw new Error(
"The Valibot adapter requires '@valibot/to-json-schema' (and 'valibot') to be installed in your project.",
)
}
const named = mod['toJsonSchema']
const fromDefault = (mod['default'] as Record<string, unknown> | undefined)?.['toJsonSchema']
const converter = typeof named === 'function' ? named : typeof fromDefault === 'function' ? fromDefault : undefined
if (!converter) {
throw new Error("'@valibot/to-json-schema' did not export 'toJsonSchema'.")
}
return converter as ToJsonSchema
}
/**
* Converts a Valibot schema into a Draft 2020-12 JSON Schema via
* `@valibot/to-json-schema`.
*
* Valibot's `date` schema has no JSON Schema representation and would otherwise
* throw, so we run with `errorMode: 'warn'` (other unsupported constructs degrade
* to an open schema rather than failing the whole conversion, and the converter
* logs which ones — so the widening is visible instead of silent) and use the
* `overrideSchema` hook to rewrite dates into the shared `x-mjst` instanceOf
* extension — the same handling TypeBox and Zod dates receive.
*/
export const valibotToJsonSchema = async (source: unknown): Promise<JSONSchema> => {
if (typeof source !== 'object' || source === null) {
const received = source === null ? 'null' : typeof source
throw new Error(`Valibot adapter expected a Valibot schema but received ${received}.`)
}
const toJsonSchema = await loadToJsonSchema()
let json: Record<string, unknown>
try {
json = toJsonSchema(source, {
errorMode: 'warn',
overrideSchema: (ctx) => {
if (ctx.valibotSchema?.type === 'date') return { [MJST_EXTENSION_KEY]: { instanceOf: 'Date' } }
if (ctx.valibotSchema?.type === 'bigint') return { [MJST_EXTENSION_KEY]: { primitive: 'bigint' } }
return undefined
},
})
} catch (error) {
throw new Error(`Valibot adapter failed to convert the schema. Is it a valid Valibot schema?\n${String(error)}`)
}
// Valibot emits a draft-07 dialect marker; the generators target 2020-12.
delete json['$schema']
return json as JSONSchema
}
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
import { describe, expect, it, vi } from 'vitest'
import * as zodModule from 'zod'
import { zodToJsonSchema } from './zod-to-json-schema'
// Resolve the `z` namespace regardless of how the installed Zod build exports it.
const z = ((zodModule as Record<string, unknown>)['z'] ??
(zodModule as Record<string, unknown>)['default'] ??
zodModule) as typeof import('zod')['z']
describe('zodToJsonSchema', () => {
it('converts a flat object with required and optional fields', async () => {
const schema = z.object({
name: z.string(),
age: z.number().int().optional(),
score: z.number().optional(),
})
const result = await zodToJsonSchema(schema)
expect(result).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
score: { type: 'number' },
},
required: ['name'],
})
expect((result as { required: string[] }).required).not.toContain('age')
expect((result as { required: string[] }).required).not.toContain('score')
})
it('strips the $schema dialect marker', async () => {
const result = await zodToJsonSchema(z.object({ name: z.string() }))
expect(result).not.toHaveProperty('$schema')
})
it('warns when a lossy (unrepresentable) Zod type is widened to "accept anything"', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await zodToJsonSchema(z.object({ id: z.string(), sym: z.symbol() }))
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Zod adapter: symbol/))
} finally {
warn.mockRestore()
}
})
it('converts enums to a string schema with an enum list', async () => {
const result = await zodToJsonSchema(z.object({ role: z.enum(['admin', 'user']) }))
expect(result).toMatchObject({
properties: { role: { type: 'string', enum: ['admin', 'user'] } },
})
})
it('converts arrays and nested objects', async () => {
const schema = z.object({
tags: z.array(z.string()),
profile: z.object({ id: z.string() }),
})
const result = await zodToJsonSchema(schema)
expect(result).toMatchObject({
properties: {
tags: { type: 'array', items: { type: 'string' } },
profile: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
})
})
it('converts a top-level scalar schema', async () => {
expect(await zodToJsonSchema(z.string())).toMatchObject({ type: 'string' })
})
it('maps z.date() to an x-mjst instanceOf Date hint', async () => {
const result = await zodToJsonSchema(z.object({ when: z.date(), name: z.string() }))
expect(result).toMatchObject({
properties: {
when: { 'x-mjst': { instanceOf: 'Date' } },
name: { type: 'string' },
},
})
// The date property must not carry a leftover JSON Schema `type`.
expect((result as { properties: { when: Record<string, unknown> } }).properties.when).not.toHaveProperty('type')
})
it('maps z.bigint() to an x-mjst primitive bigint hint', async () => {
const result = await zodToJsonSchema(z.object({ balance: z.bigint(), name: z.string() }))
expect(result).toMatchObject({
properties: {
balance: { 'x-mjst': { primitive: 'bigint' } },
name: { type: 'string' },
},
})
expect((result as { properties: { balance: Record<string, unknown> } }).properties.balance).not.toHaveProperty(
'type',
)
})
it('throws a helpful error for non-object input', async () => {
await expect(zodToJsonSchema(null)).rejects.toThrow(/expected a Zod schema but received null/)
await expect(zodToJsonSchema('nope')).rejects.toThrow(/received string/)
})
it('round-trips through the type generator, including Date fields', async () => {
const schema = z.object({
id: z.string(),
createdAt: z.date(),
score: z.number().optional(),
})
const json = await zodToJsonSchema(schema)
const typeDef = generateTypeDefinition(json, 'Event')
expect(typeDef).toContain('id: string;')
expect(typeDef).toContain('createdAt: Date;')
expect(typeDef).toContain('score?: number;')
})
})
import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension'
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
// Zod 4's `toJSONSchema` does the heavy lifting. We only describe the slice of
// its surface we touch so the adapter does not need a hard dependency on Zod's
// types — `zod` stays an optional peer dependency loaded at runtime.
type ToJsonSchema = (schema: unknown, options?: ZodToJsonSchemaOptions) => Record<string, unknown>
type OverrideContext = {
readonly zodSchema?: { readonly _zod?: { readonly def?: { readonly type?: string } } }
readonly jsonSchema: Record<string, unknown>
}
type ZodToJsonSchemaOptions = {
readonly unrepresentable?: 'any' | 'throw'
readonly override?: (ctx: OverrideContext) => void
}
/**
* Resolves Zod's `toJSONSchema` from a runtime import, tolerating both the
* named export (`import { toJSONSchema }`) and the `z` namespace
* (`z.toJSONSchema`). Throws a clear error when Zod is missing or too old,
* since `toJSONSchema` only exists in Zod 4 and later.
*/
const loadToJsonSchema = async (): Promise<ToJsonSchema> => {
let mod: Record<string, unknown>
try {
mod = (await import('zod')) as Record<string, unknown>
} catch {
throw new Error("The Zod adapter requires 'zod' (v4 or later) to be installed in your project.")
}
const named = mod['toJSONSchema']
const namespace = (mod['z'] ?? mod['default'] ?? mod) as Record<string, unknown> | undefined
const fromNamespace = namespace?.['toJSONSchema']
const converter =
typeof named === 'function' ? named : typeof fromNamespace === 'function' ? fromNamespace : undefined
if (!converter) {
throw new Error("Zod's 'toJSONSchema' was not found. The Zod adapter requires zod v4 or later.")
}
return converter as ToJsonSchema
}
/**
* Converts a Zod schema into a Draft 2020-12 JSON Schema using Zod 4's native
* `toJSONSchema`.
*
* Zod's `z.date()` has no JSON Schema representation and would otherwise throw.
* We pass `unrepresentable: 'any'` so conversion never fails on it, then use the
* `override` hook to rewrite date schemas into an `x-mjst` instanceOf hint — the
* same extension TypeBox dates use — so generated types and runtime checks treat
* them as `Date`.
*/
export const zodToJsonSchema = async (source: unknown): Promise<JSONSchema> => {
if (typeof source !== 'object' || source === null) {
const received = source === null ? 'null' : typeof source
throw new Error(`Zod adapter expected a Zod schema but received ${received}.`)
}
const toJSONSchema = await loadToJsonSchema()
// Zod types with no JSON Schema equivalent that we do *not* rescue into an
// `x-mjst` hint. `unrepresentable: 'any'` turns these into `{}` (accepts
// anything), which silently widens the generated type — so we surface them.
const LOSSY_TYPES = new Set(['symbol', 'nan', 'void', 'undefined', 'never', 'map', 'set', 'promise', 'function'])
const droppedTypes = new Set<string>()
let json: Record<string, unknown>
try {
json = toJSONSchema(source, {
unrepresentable: 'any',
override: (ctx) => {
const type = ctx.zodSchema?._zod?.def?.type
if (type === 'date') {
for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]
ctx.jsonSchema[MJST_EXTENSION_KEY] = { instanceOf: 'Date' }
} else if (type === 'bigint') {
for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]
ctx.jsonSchema[MJST_EXTENSION_KEY] = { primitive: 'bigint' }
} else if (type && LOSSY_TYPES.has(type)) {
droppedTypes.add(type)
}
},
})
} catch (error) {
throw new Error(`Zod adapter failed to convert the schema. Is it a valid Zod schema?\n${String(error)}`)
}
if (droppedTypes.size > 0) {
console.warn(
`[mjst] Zod adapter: ${[...droppedTypes].sort().join(', ')} ${droppedTypes.size === 1 ? 'has' : 'have'} no JSON Schema representation and became "accept anything". The generated type will be wider than the Zod schema.`,
)
}
// The dialect marker is noise for the generators, which already target 2020-12.
delete json['$schema']
return json as JSONSchema
}