# 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](./LICENSE.txt).
[](https://www.npmjs.com/package/valibot-serialize)
[](https://github.com/gadicc/valibot-serialize/actions/workflows/test-release.yaml)
[](https://codecov.io/gh/gadicc/valibot-serialize)
[](https://github.com/semantic-release/semantic-release)
[](http://www.typescriptlang.org/)
[](./LICENSE.txt)
## 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:
```bash
npm add valibot valibot-serialize
npm add --save-dev tsx
```
For Deno, use the JSR package and Valibot:
```bash
deno add jsr:@gadicc/valibot-serialize npm:valibot
```
## AI 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](https://skills.sh/):
```bash
npx skills add gadicc/valibot-serialize --skill valibot-serialize
```
The interactive command detects supported agents and lets you choose the
installation scope. To install it globally for Codex without prompts, run:
```bash
npx skills add gadicc/valibot-serialize --skill valibot-serialize --agent codex --global --yes
```
## Generate 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`:
```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:
1. Drizzle tables, when both `drizzle-orm` and `drizzle-valibot` are installed.
2. Valibot schemas.
Drizzle and formatter integrations are optional; core serialization and
Valibot-schema generation do not require them. Formatting is powered by
[`projectfmt`](https://github.com/gadicc/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:
```ts
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:
```ts
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:
```bash
npm run schema:gen -- --watch
```
Watch 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:
```bash
npx -p valibot-serialize vs_tocode --help
deno run --allow-read --allow-write jsr:@gadicc/valibot-serialize/vs_tocode --help
```
### Programmatic 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:
```ts
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:
```ts
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.
```ts
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](https://github.com/gadicc/valibot-serialize/blob/main/docs/GENERATOR_PLUGINS.md)
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.
```ts
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: "hello@example.com",
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 }`.
- `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.
- `SerializedSchemaV1` and `SupportedSerializedSchema`
- 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_VERSION` and `FORMAT_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](https://github.com/gadicc/valibot-serialize/blob/main/docs/SERIALIZED_FORMAT.md)
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 `fromValibot` instead of disappearing.
- Recursive lazy schemas round-trip through JSON, `toValibot`, and `toCode`.
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.
- `fromJsonSchema` is intentionally minimal and lossy. Prefer Valibot plus
`fromValibot` as the source of truth.
- Source plugins are explicit and local to each `generate` call. 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`, and
`picklist.ts`. This keeps detection/encode/decode/codegen/JSON‑Schema logic
focused and easy to maintain. When adding support for a new schema, prefer
creating `src/types/<kind>.ts` and export it via `src/types/index.ts`.
### Supported nodes and flags (AST)
- `string` with:
- lengths: `minLength`, `maxLength`, exact `length`
- 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`, ids `ulid`, `nanoid`, `cuid2`,
ISO time/date variants `isoDate`, `isoDateTime`, `isoTime`, `isoTimeSecond`,
`isoTimestamp`, `isoWeek`
- counters: `minGraphemes`, `maxGraphemes`, `minWords`, `maxWords`; word
counters retain their optional locale string or string array in
`minWordsLocales` and `maxWordsLocales`
- transforms: `trim`, `trimStart`, `trimEnd`, `toUpperCase`, `toLowerCase`,
`normalize`; explicit Unicode forms use
`{ type: "normalize", form: "NFC" | "NFD" | "NFKC" | "NFKD" }`
- `number` with `min`, `max`, `gt`, `lt`, `integer`, `safeInteger`,
`multipleOf`, `finite`
- `boolean`, `literal`
- `array` with `item` + `minLength`, `maxLength`, `length`
- `object` with `entries`, `optionalKeys` hint, `policy` (`loose`/`strict`),
`rest`, `minEntries`, `maxEntries`
- `optional`, `nullable`, `nullish`, `exactOptional`, and `undefinedable`, with
exact JSON-value defaults
- `union`, `tuple` (+ `rest`), `record`
- `enum` with `values`
- `picklist` with `values` (string options)
- `set` with `value`, `minSize`, `maxSize`
- `map` with `key`, `value`, `minSize`, `maxSize`
- `date`, `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`](https://www.npmjs.com/package/@valibot/to-json-schema)
and
[`json-schema-to-valibot`](https://www.npmjs.com/package/json-schema-to-valibot),
however, the AI offered to implement it and I said why not :) Let us know if you
find it useful.
- `toJsonSchema` converts:
- Strings to string schemas, mapping common formats and adding regexes for
selected validators (see notes).
- IDs approximated: `ulid`, `nanoid`, `cuid2` via patterns.
- Validators approximated: `creditCard`, `imei`, `mac`, `mac48`, `mac64`,
`base64` via patterns.
- 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`.
- `fromJsonSchema` converts back a subset:
- `type` string/number/integer/boolean, `const` (`literal`), `enum`,
`array`/`object`, `tuple` (`prefixItems`), `union` (`anyOf`), and `anyOf` of
constants → `picklist` (all strings) or `enum` (mixed types).
- Recognizes string format/email/uri/uuid/ipv4/ipv6, and common patterns
produced by `toJsonSchema` for 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](https://github.com/fabian-hiller/valibot/issues/30)
- [Discussion #733: Can you generate a schema from the Reflection API?](https://github.com/fabian-hiller/valibot/discussions/733)
## Development
See
[CONTRIBUTING.md](https://github.com/gadicc/valibot-serialize/blob/main/CONTRIBUTING.md)
for project layout, test naming, and workflow conventions.
## License
MIT