Valibot internals
This guide targets library authors and advanced users who want to deeply integrate with Valibot — whether you are building a form library, ORM, API framework, code generator, or other tooling.
It covers the internal object model, the pipeline execution model, Standard Schema interop, runtime introspection, schema tree traversal, and how to create fully compatible custom schemas and actions.
Architecture
Every schema and action is a plain object literal returned by a factory function. There are no classes, no prototypes beyond Object, and no shared mutable state. Each factory is annotated with // @__NO_SIDE_EFFECTS__ so bundlers can safely eliminate unused ones completely.
Do not mutate schema and action objects — treat them as immutable. If you need to create a modified copy, see the source code of the fallback method for a minimal example of how to do this correctly.
Schemas
Schemas are the starting point for using Valibot. They validate a specific data type, like a string, object, or date, and can be reused or nested to reflect more complex data structures. Every schema is a plain object that satisfies BaseSchema:
| Property | Type | Description |
|---|---|---|
kind | 'schema' | Identifies this object as a schema |
type | string | snake_case name, e.g. 'string', 'loose_object' |
reference | Function | The factory function itself (for identity checks) |
expects | string | Human-readable expected type, e.g. 'string' |
async | false | true on async variants |
'~standard' | StandardProps | Standard Schema v1 properties (lazy getter) |
'~run' | Function | Parses an UnknownDataset and returns an output dataset |
'~types' | undefined | Phantom field for TypeScript inference only — always undefined at runtime |
Validation logic beyond the base type check lives in a pipe array added by the pipe method and some schemas expose additional schema specific properties. See Runtime properties for a full breakdown.
Actions
Actions come in three kinds. The first and probably most important one are validation actions. They check an already-typed value and may add issues. Every validation action is a plain object that satisfies BaseValidation:
| Property | Type | Description |
|---|---|---|
kind | 'validation' | Identifies this object as a validation action |
type | string | snake_case name, e.g. 'min_length', 'email' |
reference | Function | The factory function itself (for identity checks) |
expects | string | null | Human-readable expected value description; used in issue messages |
async | false | true on async variants |
'~run' | Function | Validates the current dataset value |
The second one are transformation actions. They convert the value to a new type and/or value. Every transformation action is a plain object that satisfies BaseTransformation:
| Property | Type | Description |
|---|---|---|
kind | 'transformation' | Identifies this object as a transformation action |
type | string | snake_case name, e.g. 'trim', 'to_lower_case' |
reference | Function | The factory function itself (for identity checks) |
async | false | true on async variants |
'~run' | Function | Transforms the current dataset value |
The third one are metadata actions. They carry static annotations and are always skipped during pipeline execution. Every metadata action is a plain object that satisfies BaseMetadata:
| Property | Type | Description |
|---|---|---|
kind | 'metadata' | Identifies this object as a metadata action |
type | string | snake_case name, e.g. 'title', 'description' |
reference | Function | The factory function itself (for identity checks) |
Datasets
A dataset is the container that carries a value through the validation pipeline. It is passed to each '~run' method in sequence, and as the pipeline executes, the dataset's typed flag and issues array are updated to reflect the current state of validation.
Datasets are mutable by design for performance reasons. '~run' implementations modify dataset.value and dataset.typed in place rather than returning new objects.
| Type | typed | issues | Description |
|---|---|---|---|
UnknownDataset | undefined | undefined | Raw input, not yet validated |
SuccessDataset<T> | true | undefined | Fully typed, no issues |
PartialDataset<T, Issue> | true | [Issue, ...Issue[]] | Typed but has formatting issues |
FailureDataset<Issue> | false | [Issue, ...Issue[]] | Not typed, has fatal issues |
Pipe execution
The pipe method creates a copy of the root schema that contains a pipe property — a tuple with the root schema at index 0 and actions at index 1+. It also replaces '~run' with a new implementation that iterates all items in the tuple in order:
function pipe(...pipe) {
return {
// Spread all properties of the root schema
...pipe[0],
// Add the pipe tuple (root schema at index 0, other schemas and actions at index 1+)
pipe,
// Replace '~standard' with a lazy getter so that `this` refers to the new schema object
get '~standard'() {
return _getStandardProps(this);
},
// Replace '~run' with a new implementation that executes the pipeline
'~run'(dataset, config) {
for (const item of pipe) {
// Metadata actions are never executed
if (item.kind !== 'metadata') {
// Schemas and transformations cannot run if there are already issues
if (
dataset.issues &&
(item.kind === 'schema' || item.kind === 'transformation')
) {
dataset.typed = false;
break;
}
// Run pipe item unless an early abort is configured
if (
!dataset.issues ||
(!config.abortEarly && !config.abortPipeEarly)
) {
dataset = item['~run'](dataset, config);
}
}
}
return dataset;
},
};
}
The following rules apply during pipe execution:
- Metadata items are always skipped.
- Schemas and transformations abort if the dataset already has issues.
- Validations continue across existing issues unless
abortEarlyorabortPipeEarlyis configured.
Integrate Valibot
This section explains how to integrate Valibot into external tools. It covers using Standard Schema for schema-agnostic integrations, extracting static types and runtime properties for introspection, and traversing schema trees for code generation and analysis.
Standard Schema
Valibot fully implements Standard Schema v1. Every schema object exposes a '~standard' property that returns a StandardProps object:
interface StandardProps<TInput, TOutput> {
readonly version: 1;
readonly vendor: 'valibot';
readonly validate: (
value: unknown
) => StandardResult<TOutput> | Promise<StandardResult<TOutput>>;
readonly types?: { readonly input: TInput; readonly output: TOutput };
}
'~standard' is implemented as a lazy getter so the StandardProps object is only created when first accessed.
When building a library that accepts user-defined schemas, we recommend using ~standard.validate instead of Valibot specific APIs — unless your integration is Valibot-specific and requires APIs that Standard Schema does not expose. This ensures your library works with any Standard Schema and not just Valibot.
import type { StandardSchemaV1 } from '@standard-schema/spec';
async function validateData(schema: StandardSchemaV1, data: unknown) {
const result = await schema['~standard'].validate(data);
// ...
}
Valibot also supports the Standard JSON Schema specification via the
@valibot/to-json-schemapackage, which exposes atoStandardJsonSchemafunction.
Schema introspection
Valibot schemas are plain objects, so all their properties are readable at runtime. This section covers how to extract static TypeScript types, read runtime properties, and use built-in type guards to narrow schema values safely.
Static types
Valibot exposes three generic utility types for extracting type information from any schema, validation, transformation, or metadata object.
import * as v from 'valibot';
const Schema = v.pipe(v.string(), v.decimal(), v.toNumber());
type Input = v.InferInput<typeof Schema>; // string
type Output = v.InferOutput<typeof Schema>; // number
type Issue = v.InferIssue<typeof Schema>; // StringIssue | DecimalIssue | ToNumberIssue
InferInput,
InferOutput, and
InferIssue read the phantom '~types'
field. They work on schemas, validations, transformations, and metadata alike.
'~types' is always undefined at runtime — this field exists solely for
TypeScript's type inference. Never read it in runtime code.
Runtime properties
Every schema and action is a plain object, so you can read its properties directly at runtime. The base properties (kind, type, async, etc.) are always present. Use kind to distinguish schemas from actions, and type to identify specific schemas and actions. Some schemas expose additional properties listed in the table below.
| Schema | Extra property | Description |
|---|---|---|
object, looseObject, strictObject | entries | Record<string, BaseSchema> of named fields |
objectWithRest | entries, rest | named fields + rest element schema |
array | item | element schema |
tuple, looseTuple, strictTuple | items | ordered tuple of element schemas |
tupleWithRest | items, rest | ordered elements + rest element schema |
record, map | key, value | key and value schemas |
set | value | value schema |
union, intersect | options | array of member schemas |
variant | key, options | discriminant key string + array of object schemas |
optional, nullable, and other wrappers | wrapped | inner schema |
lazy | getter | (input: unknown) => BaseSchema deferred getter |
Type guards
Use these helpers to narrow the TypeScript type of an unknown Valibot object before accessing its properties. Valibot exports three type guard helpers — isOfKind, isOfType, and isValiError — that narrow kind and type with TypeScript inference:
import * as v from 'valibot';
// Narrows by kind
if (v.isOfKind('schema', item)) {
// item is BaseSchema<...>
}
// Narrows by type
if (v.isOfType('string', schema)) {
// schema is StringSchema<...>
}
// Checks if an error is a ValiError
try {
v.parse(Schema, input);
} catch (error) {
if (v.isValiError<typeof Schema>(error)) {
// error is ValiError<typeof Schema>
console.log(error.issues);
}
}
Direct === comparisons on kind and type are fine too, but isOfKind and isOfType can be handy in special cases to narrow the TypeScript type.
Schema tree traversal
Because schemas are plain objects, you can walk a schema tree by reading its properties (see Runtime properties). Here is a simplified version of getDefaults extracting deeply nested default values from object and tuple schemas:
import * as v from 'valibot';
function getDefaults<
const TSchema extends
| v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>
| v.ObjectSchema<v.ObjectEntries, v.ErrorMessage<v.ObjectIssue> | undefined>
| v.TupleSchema<v.TupleItems, v.ErrorMessage<v.TupleIssue> | undefined>,
>(schema: TSchema): v.InferDefaults<TSchema> {
// If it is an object schema, return defaults of entries
if ('entries' in schema) {
const object: Record<string, unknown> = {};
for (const key in schema.entries) {
object[key] = getDefaults(schema.entries[key]);
}
return object;
}
// If it is a tuple schema, return defaults of items
if ('items' in schema) {
return schema.items.map(getDefaults);
}
// Otherwise, return default or `undefined`
return v.getDefault(schema);
}
Extend Valibot
This section explains how to build custom schemas and actions from scratch. It covers the required object shapes and how to use internal utilities.
Custom schemas
A custom schema has three parts: a typed issue interface extending BaseIssue, a typed schema interface extending BaseSchema, and a factory function returning the plain object.
The _getStandardProps and _addIssue utilities handle the Standard Schema boilerplate and issue reporting. Here is how Valibot's own string schema is implemented:
import * as v from 'valibot';
export interface StringIssue extends v.BaseIssue<unknown> {
readonly kind: 'schema';
readonly type: 'string';
readonly expected: 'string';
}
export interface StringSchema<
TMessage extends v.ErrorMessage<StringIssue> | undefined,
> extends v.BaseSchema<string, string, StringIssue> {
readonly type: 'string';
readonly reference: typeof string;
readonly expects: 'string';
readonly message: TMessage;
}
export function string(): StringSchema<undefined>;
export function string<
const TMessage extends v.ErrorMessage<StringIssue> | undefined,
>(message: TMessage): StringSchema<TMessage>;
export function string(
message?: v.ErrorMessage<StringIssue>
): StringSchema<v.ErrorMessage<StringIssue> | undefined> {
return {
kind: 'schema',
type: 'string',
reference: string,
expects: 'string',
async: false,
message,
get '~standard'() {
return v._getStandardProps(this);
},
'~run'(dataset, config) {
if (typeof dataset.value === 'string') {
// @ts-expect-error
dataset.typed = true;
} else {
v._addIssue(this, 'type', dataset, config);
}
// @ts-expect-error
return dataset as v.OutputDataset<string, StringIssue>;
},
};
}
Functions prefixed with an underscore (e.g.
_addIssue,_getStandardProps) are internal to the library. They are exported for advanced use cases but should be used with caution, as their signatures may change between minor versions.
Custom actions
Custom actions follow the same plain-object pattern as schemas. Valibot has three action kinds — BaseValidation, BaseTransformation, and BaseMetadata — each with its own kind string.
Here is how Valibot's own email validation action is implemented:
import * as v from 'valibot';
const EMAIL_REGEX =
/^[\w+-]+(?:\.[\w+-]+)*@[\w+-]+(?:\.[\w+-]+)*\.[a-zA-Z]{2,}$/iu;
export interface EmailIssue<TInput extends string> extends v.BaseIssue<TInput> {
readonly kind: 'validation';
readonly type: 'email';
readonly expected: null;
readonly received: `"${string}"`;
readonly requirement: RegExp;
}
export interface EmailAction<
TInput extends string,
TMessage extends v.ErrorMessage<EmailIssue<TInput>> | undefined,
> extends v.BaseValidation<TInput, TInput, EmailIssue<TInput>> {
readonly type: 'email';
readonly reference: typeof email;
readonly expects: null;
readonly requirement: RegExp;
readonly message: TMessage;
}
export function email<TInput extends string>(): EmailAction<TInput, undefined>;
export function email<
TInput extends string,
const TMessage extends v.ErrorMessage<EmailIssue<TInput>> | undefined,
>(message: TMessage): EmailAction<TInput, TMessage>;
export function email(
message?: v.ErrorMessage<EmailIssue<string>>
): EmailAction<string, v.ErrorMessage<EmailIssue<string>> | undefined> {
return {
kind: 'validation',
type: 'email',
reference: email,
expects: null,
async: false,
requirement: EMAIL_REGEX,
message,
'~run'(dataset, config) {
if (dataset.typed && !this.requirement.test(dataset.value)) {
v._addIssue(this, 'email', dataset, config);
}
return dataset;
},
};
}
Dynamic schemas
You can compose schemas dynamically by wrapping them in a generic function. Use GenericSchema as the constraint — it is an alias for BaseSchema with all type parameters defaulting to unknown, designed specifically for this purpose. TypeScript propagates the concrete type through InferOutput, so the return type is fully inferred.
An example use case is wrapping a user-provided item schema in a standard pagination envelope:
import * as v from 'valibot';
function paginatedList<TItem extends v.GenericSchema>(item: TItem) {
return v.object({
items: v.array(item),
total: v.number(),
page: v.number(),
});
}
const UserList = paginatedList(v.object({ id: v.number(), name: v.string() }));
type UserList = v.InferOutput<typeof UserList>;
// {
// items: { id: number; name: string }[];
// total: number;
// page: number;
// }