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:

PropertyTypeDescription
kind'schema'Identifies this object as a schema
typestringsnake_case name, e.g. 'string', 'loose_object'
referenceFunctionThe factory function itself (for identity checks)
expectsstringHuman-readable expected type, e.g. 'string'
asyncfalsetrue on async variants
'~standard'StandardPropsStandard Schema v1 properties (lazy getter)
'~run'FunctionParses an UnknownDataset and returns an output dataset
'~types'undefinedPhantom 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:

PropertyTypeDescription
kind'validation'Identifies this object as a validation action
typestringsnake_case name, e.g. 'min_length', 'email'
referenceFunctionThe factory function itself (for identity checks)
expectsstring | nullHuman-readable expected value description; used in issue messages
asyncfalsetrue on async variants
'~run'FunctionValidates 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:

PropertyTypeDescription
kind'transformation'Identifies this object as a transformation action
typestringsnake_case name, e.g. 'trim', 'to_lower_case'
referenceFunctionThe factory function itself (for identity checks)
asyncfalsetrue on async variants
'~run'FunctionTransforms 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:

PropertyTypeDescription
kind'metadata'Identifies this object as a metadata action
typestringsnake_case name, e.g. 'title', 'description'
referenceFunctionThe 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.

TypetypedissuesDescription
UnknownDatasetundefinedundefinedRaw input, not yet validated
SuccessDataset<T>trueundefinedFully 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 abortEarly or abortPipeEarly is 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-schema package, which exposes a toStandardJsonSchema function.

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.

SchemaExtra propertyDescription
object, looseObject, strictObjectentriesRecord<string, BaseSchema> of named fields
objectWithRestentries, restnamed fields + rest element schema
arrayitemelement schema
tuple, looseTuple, strictTupleitemsordered tuple of element schemas
tupleWithRestitems, restordered elements + rest element schema
record, mapkey, valuekey and value schemas
setvaluevalue schema
union, intersectoptionsarray of member schemas
variantkey, optionsdiscriminant key string + array of object schemas
optional, nullable, and other wrapperswrappedinner schema
lazygetter(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;
// }

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @antfu
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @saturnonearth
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @KubaJastrz
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @dslatkin