valibot-serialize
v2.0.1
Published
Generate deterministic, tree-shakeable Valibot modules and serialize schemas as a portable AST
Downloads
488
Maintainers
Readme
valibot-serialize
Generate deterministic, tree-shakeable Valibot modules from Valibot schemas and optional Drizzle tables, through a CLI or non-exiting programmatic API.
The generator is built on a stable, portable serialized AST that is also available directly for schema storage, migration, reconstruction, and code generation.
Copyright (c) 2025 by Gadi Cohen. MIT licensed.
Install and runtimes
The npm package supports Node.js 22 and later. Install tsx, its required CLI
runtime peer, when using the packaged vs_tocode binary:
npm add valibot valibot-serialize
npm add --save-dev tsxFor Deno, use the JSR package and Valibot:
deno add jsr:@gadicc/valibot-serialize npm:valibotAI agent skill
This repository includes a valibot-serialize skill that gives compatible AI
coding agents package-aware generation, serialization, migration, conversion,
and source-plugin guidance. Add it to a project with the
skills CLI:
npx skills add gadicc/valibot-serialize --skill valibot-serializeThe interactive command detects supported agents and lets you choose the installation scope. To install it globally for Codex without prompts, run:
npx skills add gadicc/valibot-serialize --skill valibot-serialize --agent codex --global --yesGenerate schemas
The generator scans selected modules for supported exports and writes static
Valibot modules. A typical project keeps generation and CI verification next to
each other in package.json:
{
"scripts": {
"schema:gen": "vs_tocode --include 'src/db/schema/*.ts' --out-dir src/db/valibot/generated --formatter=none",
"schema:check": "vs_tocode --include 'src/db/schema/*.ts' --out-dir src/db/valibot/generated --formatter=none --check"
}
}Run npm run schema:gen to write the modules. Run npm run schema:check in CI
to compare the exact generated text without writing; it exits nonzero when a
selected source's output is missing or different. Check mode covers selected
inputs only and does not find orphan outputs whose source is no longer selected.
Generated files can be committed for reviewable diffs or produced as a build
step. Whichever policy you choose, keep it consistent and run schema:check
when committed output must stay synchronized. Output is deterministic for
identical sources, options, plugin and dependency versions, and an explicit
formatter choice. Select a pinned formatter or none in CI instead of the
environment-sensitive auto mode.
Built-in sources and optional integrations
The built-in handlers run in stable order:
- Drizzle tables, when both
drizzle-ormanddrizzle-valibotare installed. - Valibot schemas.
Drizzle and formatter integrations are optional; core serialization and
Valibot-schema generation do not require them. Formatting is powered by
projectfmt, which uses project-local
Prettier or Biome configuration (and dependencies), or Deno fmt, when explicitly
selected or detected by auto.
For example, this Drizzle input:
import { integer, pgTable, text } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: integer().generatedAlwaysAsIdentity().primaryKey(),
name: text().notNull(),
});produces tree-shakeable Valibot exports shaped like:
export const usersSelect = v.object({
/* generated fields */
});
export const usersInsert = v.object({
/* generated insert fields */
});
export const usersUpdate = v.object({
/* generated optional update fields */
});
export type UsersSelect = v.InferOutput<typeof usersSelect>;
export type UsersInsert = v.InferInput<typeof usersInsert>;
export type UsersUpdate = v.InferInput<typeof usersUpdate>;If you need a different Drizzle-derived shape, use drizzle-valibot directly,
export the Valibot schemas you want, and let the Valibot handler generate them.
Watch workflow
Pass --watch to keep selected outputs converged as sources change:
npm run schema:gen -- --watchWatch mode owns only outputs mapped from the selected inputs. It serializes event handling, rejects output collisions, removes only outputs it owns, and keeps running after a per-file generation error so a later edit can recover.
For one-off use, the same binary can be run directly:
npx -p valibot-serialize vs_tocode --help
deno run --allow-read --allow-write jsr:@gadicc/valibot-serialize/vs_tocode --helpProgrammatic generation
Import the non-exiting API from the dedicated subpath. Errors reject the returned promise; the API never terminates its host process or sets an exit status. A caller that enables watch mode owns the returned lifecycle:
import { generate } from "valibot-serialize/vs_tocode";
const result = await generate({
explicitFiles: ["src/db/schema/users.ts"],
// Or select files with: include: ["src/db/schema/*.ts"],
outDir: "src/db/valibot/generated",
formatter: "none",
watch: true,
});
console.log(`Generated ${result.files.length} file(s)`);
// Close during application shutdown. close() is safe to call more than once.
await result.watch?.close();
await result.watch?.done;Programmatic check mode returns structured, canonically ordered mismatches and does not write or remove files:
const result = await generate({
explicitFiles: ["src/db/schema/users.ts"],
outDir: "src/db/valibot/generated",
formatter: "none",
check: true,
});
if (!result.check?.upToDate) {
console.error(result.check?.mismatches);
}Relative inputs, globs, and output paths resolve from projectRoot, which
defaults to the current working directory. Check mode cannot be combined with
watch or dryRun.
Explicit source plugins
Third-party sources are explicit: pass handlers to replace the built-ins, and
include builtInHandlers yourself when composing custom and built-in behavior.
Supplied order is preserved and every synchronous or asynchronous hook is
awaited in that order.
import {
builtInHandlers,
defineSourcePlugin,
generate,
} from "valibot-serialize/vs_tocode";
const taggedStringPlugin = defineSourcePlugin({
name: "tagged-string",
available: () => true,
test: (value) =>
typeof value === "object" && value !== null &&
(value as { kind?: unknown }).kind === "tagged-string",
transform: (symbol) => ({ exports: { [symbol]: "v.string()" } }),
});
await generate({
explicitFiles: ["src/schemas.ts"],
outDir: "src/generated",
formatter: "none",
handlers: [taggedStringPlugin, ...builtInHandlers],
});See the source plugin guide for stable types, contexts, failure behavior, and composition rules. Plugins are never discovered implicitly.
Serialize schemas
The serialized AST is a versioned JSON wire format and the foundation of the generator. It is also a public API for applications that need to store, transport, inspect, migrate, or reconstruct schemas.
import * as v from "valibot";
import * as vs from "valibot-serialize";
const LoginSchema = v.object({
email: v.string(),
password: v.string(),
});
const serialized = vs.fromValibot(LoginSchema);
const wireText = JSON.stringify(serialized);
const received: unknown = JSON.parse(wireText);
const migrated = vs.migrateSerializedSchema(received);
const NewLoginSchema = vs.toValibot(migrated);
const parsed = v.parse(NewLoginSchema, {
email: "[email protected]",
password: "password",
});
const code = vs.toCode(migrated);
// "v.object({email:v.string(),password:v.string()})"Serialization API
fromValibot(schema: v.BaseSchema): SerializedSchema- Encodes a Valibot schema to a JSON‑serializable AST with
{ kind, vendor, version, format, node }.
- Encodes a Valibot schema to a JSON‑serializable AST with
toValibot(data: SupportedSerializedSchema): v.BaseSchema- Decodes current format 2 or supported legacy format 1 back to a Valibot schema.
isSerializedSchema(x: unknown): x is SerializedSchema- Runtime type guard for the current format-2 AST envelope only.
migrateSerializedSchema(x: unknown): SerializedSchema- Validates format 1 or 2 and returns a detached canonical format-2 payload.
SerializedSchemaV1andSupportedSerializedSchema- Exact legacy input and current-or-legacy reader types.
serializedSchemaJson- JSON Schema for the AST envelope and node variants (useful to validate serialized payloads).
ENVELOPE_VERSIONandFORMAT_VERSION- Current routing-envelope and serialized-AST versions for public format introspection.
toJsonSchema(serialized: SupportedSerializedSchema): JsonSchema- Best‑effort conversion from our AST to JSON Schema (Draft 2020‑12) for data validation.
fromJsonSchema(json: JsonSchemaLike): SerializedSchema- Basic, lossy converter from a subset of JSON Schema → our AST (strings/numbers/booleans/literals/arrays/objects/enums/unions/tuples/sets/maps approximations).
toCode(serialized: SupportedSerializedSchema): string- Emits concise Valibot builder code for the given AST (no imports). Intended for code‑gen/export; format it as you like.
Format support
The serialized-format compatibility policy defines the envelope and AST version boundaries. In summary:
| Surface | Format support |
| ----------------------------------------------- | ------------------------------------------- |
| fromValibot writer and FORMAT_VERSION | Emits canonical format 2 |
| isSerializedSchema and serializedSchemaJson | Validate canonical format 2 |
| migrateSerializedSchema | Reads format 1 or 2 and returns format 2 |
| toValibot, toCode, and toJsonSchema | Read supported format 1 or current format 2 |
Format 2 represents repeated or recursive schema identities with canonical JSON
Pointers such as { type: "reference", path: "#/node" }. Format-1 libraries
cannot read format-2 output.
Limitations and non-goals
- Arbitrary transforms, callbacks, and accessors cannot be serialized. Apply
custom behavior outside the serialized schema. Unsupported pipe actions fail
during
fromValibotinstead of disappearing. - Recursive lazy schemas round-trip through JSON,
toValibot, andtoCode. Recursive data JSON Schema conversion remains unsupported and throws a controlled cyclic-schema error. - Wrapper defaults round-trip only when they are exact JSON values:
null, strings, booleans, finite numbers other than negative zero, dense arrays, and plain objects containing the same values. Callback defaults and richer runtime values fail without invoking callbacks or accessors. Default containers are limited to 256 levels and 10,000 visited containers. Proxies are unsupported. fromJsonSchemais intentionally minimal and lossy. Prefer Valibot plusfromValibotas the source of truth.- Source plugins are explicit and local to each
generatecall. There is no implicit discovery, generalized target-plugin system, or automatic orphan cleanup.
Detailed reference
Module structure
- Each Valibot schema kind is implemented in its own module under
src/types/. For example:string.ts,number.ts,object.ts,enum.ts, andpicklist.ts. This keeps detection/encode/decode/codegen/JSON‑Schema logic focused and easy to maintain. When adding support for a new schema, prefer creatingsrc/types/<kind>.tsand export it viasrc/types/index.ts.
Supported nodes and flags (AST)
stringwith:- lengths:
minLength,maxLength, exactlength - patterns:
pattern(+patternFlags),startsWith,endsWith - formats/validators:
email,rfcEmail,url,uuid,ip,ipv4,ipv6,hexColor,slug,digits,emoji,hexadecimal,creditCard,imei,mac,mac48,mac64,base64, idsulid,nanoid,cuid2, ISO time/date variantsisoDate,isoDateTime,isoTime,isoTimeSecond,isoTimestamp,isoWeek - counters:
minGraphemes,maxGraphemes,minWords,maxWords; word counters retain their optional locale string or string array inminWordsLocalesandmaxWordsLocales - transforms:
trim,trimStart,trimEnd,toUpperCase,toLowerCase,normalize; explicit Unicode forms use{ type: "normalize", form: "NFC" | "NFD" | "NFKC" | "NFKD" }
- lengths:
numberwithmin,max,gt,lt,integer,safeInteger,multipleOf,finiteboolean,literalarraywithitem+minLength,maxLength,lengthobjectwithentries,optionalKeyshint,policy(loose/strict),rest,minEntries,maxEntriesoptional,nullable,nullish,exactOptional, andundefinedable, with exact JSON-value defaultsunion,tuple(+rest),recordenumwithvaluespicklistwithvalues(string options)setwithvalue,minSize,maxSizemapwithkey,value,minSize,maxSizedate,file(minSize,maxSize,mimeTypes),blob(minSize,maxSize,mimeTypes)
JSON Schema conversion
This was never a main goal for the project especially since other, mature tools
exist for this purpose (i.e.
@valibot/to-json-schema
and
json-schema-to-valibot,
however, the AI offered to implement it and I said why not :) Let us know if you
find it useful.
toJsonSchemaconverts:- Strings to string schemas, mapping common formats and adding regexes for
selected validators (see notes).
- IDs approximated:
ulid,nanoid,cuid2via patterns. - Validators approximated:
creditCard,imei,mac,mac48,mac64,base64via patterns.
- IDs approximated:
- Numbers, booleans, arrays, objects, tuples, enums, unions, sets/maps (approximate), records (as additionalProperties), date/file/blob as strings (binary for file/blob).
- Union of literals becomes an
enum.
- Strings to string schemas, mapping common formats and adding regexes for
selected validators (see notes).
fromJsonSchemaconverts back a subset:typestring/number/integer/boolean,const(literal),enum,array/object,tuple(prefixItems),union(anyOf), andanyOfof constants →picklist(all strings) orenum(mixed types).- Recognizes string format/email/uri/uuid/ipv4/ipv6, and common patterns
produced by
toJsonSchemafor startsWith/endsWith,hexColor,slug,digits,hexadecimal, ids (ulid,nanoid,cuid2) and sets flags accordingly.
Compatibility mapping (selected)
| Valibot/AST | toJsonSchema | fromJsonSchema back | | -------------------------- | ------------------------------------- | ------------------- | | string.email | type: string, format: email | email: true | | string.url | type: string, format: uri | url: true | | string.uuid | type: string, format: uuid | uuid: true | | string.ipv4/ipv6 | format: ipv4/ipv6 | ipv4/ipv6: true | | string.ip | anyOf [ipv4, ipv6] | ip: true | | string.startsWith/endsWith | pattern/allOf anchored | starts/ends: true | | string.hexColor | regex | hexColor: true | | string.slug | regex | slug: true | | string.digits/hexadecimal | regex | digits/hexadecimal | | ulid/nanoid/cuid2 | regex | flags: true | | creditCard/imei/mac/... | regex | flags: true | | number min/max/gt/lt | min/max/exclusiveMin/Max | fields restored | | array min/max/len | minItems/maxItems | fields restored | | object min/max entries | minProperties/maxProperties | fields restored | | union of literals | enum | enum node | | enum values | enum | enum node | | set/map | array uniqueItems / object additional | approximated | | tuple/rest | prefixItems (+ items/rest) | fields restored | | date | string (format: date-time) | approximated | | file/blob | string binary (+ mediaType) | approximated |
Creation Notes
This was "vibe-coded" (with AI) over a weekend. I set up minimalist structure with a test case for how I wanted the code to work, and some empty functions with signatures. I then asked OpenAI Codex to complete the code.
Codex did so, and consistently gave some great suggestions on what to do next, and I kept saying yes to see where it would go. Eventually then I moved on to prompts for cleanup, refactoring, project structure, etc. The CLI tool was written by hand.
Please do bring any weird issues to our attention, and feel free to request clearer docs, examples, etc. Working on that next.
Relevant issues / discussions on valibot repo:
- Issue #30: Allow schema serialization
- Discussion #733: Can you generate a schema from the Reflection API?
Development
See CONTRIBUTING.md for project layout, test naming, and workflow conventions.
License
MIT
