In client-side web applications (Next.js Client Components, SPAs, micro-frontends), JavaScript bundle weight directly dictates Core Web Vitals performance metrics: specifically Total Blocking Time (TBT) and Interaction to Next Paint (INP). The larger the downloaded script payloads, the longer mobile CPUs spend parsing and compiling JavaScript inside V8, freezing the browser's main UI thread.
Historically, the ecosystem's dominant library—Zod—was engineered around an object-oriented, fluent method-chaining model:
z.string().min(5).max(100).email().trim();Under this class-based architecture, every conceivable validation and transformation method is hardwired directly into the prototype of ZodString and its base class ZodType. Modern bundler Tree-Shaking algorithms (Rollup, Webpack, Turbopack, Vite) analyze static import graphs at the ES module level. Because class methods are bound to a single monolithic object, the bundler cannot determine which prototype methods are used and which are dead code. Consequently, importing a single primitive like z.string() pulls the entire monolithic Zod library (approx. 55–70 KB uncompressed / ~14 KB gzipped) into the client bundle.
Valibot resolves this architectural bottleneck through pure functional decomposition. In Valibot, monolithic classes are eliminated. Every primitive schema (string, number), every validator (email, minLength, regex), and every transformation (trim, toLowerCase) is an isolated, independently exported higher-order function.
The foundational bridge is the pipe() pipeline, which composes a base schema with a sequence of validation and transformation Actions. The bundler bundles only the functions explicitly imported by the application, slashing client-side validation payloads down to under 1 KB (gzip) (a 90–95% size reduction).
Contrasting an OOP method chain against modular functional composition illustrates how tree-shaking operates:
+-------------------------------------------------------------------------+
| ZOD MONOLITHIC GRAPH (Tree-Shaking Impossible) |
+-------------------------------------------------------------------------+
| import { z } from 'zod'; |
| z.string().email(); |
| |
| Bundler inspects the ZodString class: |
| class ZodString { |
| min() { ... } // <- Retained in bundle even if never called! |
| max() { ... } // <- Retained in bundle! |
| uuid() { ... } // <- Retained in bundle! |
| regex() { ... } // <- Retained in bundle! |
| ip() { ... } // <- Retained in bundle! |
| } |
| => Total bundle footprint: ~14 KB (gzip) |
+-------------------------------------------------------------------------+
| |
| VALIBOT MODULAR GRAPH (Full Tree-Shaking) |
+-------------------------------------------------------------------------+
| import { object, string, email, pipe } from 'valibot'; |
| |
| Bundler analyzes exported ES symbols: |
| - object() => Bundled (~350 bytes) |
| - string() => Bundled (~150 bytes) |
| - email() => Bundled (~200 bytes) |
| - pipe() => Bundled (~100 bytes) |
| * All other 150+ library exports are completely purged from build! |
| => Total bundle footprint: ~850 bytes (gzip)! |
+-------------------------------------------------------------------------+The initial argument of pipe() checks the primitive type (string, number, object). If mismatched, the pipeline aborts immediately.
Data flows linearly through transformations (trim, toLowerCase) and constraint checkers (minLength, email).
Upon failure, actions emit detailed SchemaIssue structures with precise path tracking and localized messages.
safeParse returns a discriminated union: { success: true, output } or { success: false, issues }.
Let us implement user profile validation using Valibot:
import * as v from 'valibot';
// Constructing a schema via functional composition with pipe()
export const UserProfileSchema = v.object({
id: v.pipe(
v.string(),
v.uuid('Identifier must be a valid UUID v4')
),
username: v.pipe(
v.string(),
v.trim(), // Transformation: strip whitespace
v.minLength(3, 'Username must be at least 3 characters'),
v.maxLength(20, 'Username must not exceed 20 characters')
),
email: v.pipe(
v.string(),
v.trim(),
v.toLowerCase(), // Transformation: normalize casing
v.email('Invalid email address format')
),
age: v.pipe(
v.number(),
v.minValue(18, 'Service is restricted to users 18 and older'),
v.maxValue(100, 'Please enter a valid age')
),
website: v.optional(
v.pipe(
v.string(),
v.url('Invalid URL format')
)
),
});
// Static inference of incoming raw data (pre-transformations)
export type UserProfileInput = v.InferInput<typeof UserProfileSchema>;
// Static inference of processed data (post-trim and post-toLowerCase)
export type UserProfileOutput = v.InferOutput<typeof UserProfileSchema>;[!NOTE] Note the explicit separation between
InferInputandInferOutput. Because the pipeline includes transformations (trim(),toLowerCase()), the shape of the raw input can differ from the validated output, which TypeScript tracks with complete type safety.
Valibot enables writing custom validators without library bloat using check or lower-level rawCheck:
import * as v from 'valibot';
// Custom password strength validator action
export function passwordStrength(requirementMessage = 'Password must contain at least 1 number and 1 special symbol') {
return v.check((input: string) => {
const hasNumber = /\d/.test(input);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(input);
return hasNumber && hasSpecialChar;
}, requirementMessage);
}
// Custom VAT/Tax ID checksum validation action (e.g., 10-digit number with checksum)
export function isValidTaxId(errorMessage = 'Invalid enterprise tax identifier format') {
return v.check((input: string) => {
if (!/^\d{10}$/.test(input)) return false;
const coefficients = [2, 4, 10, 3, 5, 9, 4, 6, 8];
const controlSum = coefficients.reduce(
(sum, coeff, idx) => sum + coeff * Number(input[idx]),
0
);
const controlDigit = (controlSum % 11) % 10;
return Number(input[9]) === controlDigit;
}, errorMessage);
}
// Using custom actions inside a schema pipeline
export const SecurityFormSchema = v.object({
taxId: v.pipe(
v.string(),
v.trim(),
isValidTaxId()
),
password: v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters long'),
passwordStrength()
),
});In client-side forms, always employ safeParse to prevent uncaught runtime exceptions:
import * as v from 'valibot';
import { UserProfileSchema, UserProfileOutput } from './profile-schema';
export interface FormSubmissionResult {
isSuccess: boolean;
data?: UserProfileOutput;
fieldErrors?: Record<string, string>;
}
export function handleProfileSubmission(rawFormData: unknown): FormSubmissionResult {
// safeParse returns a typed outcome object without throwing exceptions
const result = v.safeParse(UserProfileSchema, rawFormData);
if (!result.success) {
// Transform flat issues array into a field-keyed error dictionary
const fieldErrors: Record<string, string> = {};
for (const issue of result.issues) {
// Extract first path segment name (e.g., 'email' or 'username')
const fieldName = issue.path?.[0]?.key;
if (typeof fieldName === 'string' && !fieldErrors[fieldName]) {
fieldErrors[fieldName] = issue.message;
}
}
return {
isSuccess: false,
fieldErrors,
};
}
// Data is fully verified and transformed
return {
isSuccess: true,
data: result.output,
};
}Here is an architectural module featuring domain validators for authentication workflows:
import * as v from 'valibot';
// 1. Reusable primitive building blocks
export const EmailSchema = v.pipe(
v.string(),
v.trim(),
v.toLowerCase(),
v.email('Invalid corporate email syntax'),
v.maxLength(100, 'Email must not exceed 100 characters')
);
export const PasswordSchema = v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters long'),
v.maxLength(64, 'Password must not exceed 64 characters')
);
// 2. Login schema
export const LoginSchema = v.object({
email: EmailSchema,
password: PasswordSchema,
rememberMe: v.optional(v.boolean(), false),
});
// 3. Registration schema with password confirmation matching via forward/check
export const RegistrationSchema = v.pipe(
v.object({
email: EmailSchema,
password: PasswordSchema,
confirmPassword: v.string(),
agreeToTerms: v.pipe(
v.boolean(),
v.check((val) => val === true, 'You must accept the terms of service')
),
}),
// Cross-field comparison at object level
v.forward(
v.check(
(input) => input.password === input.confirmPassword,
'Passwords do not match'
),
['confirmPassword'] // Associates the error with the confirmPassword field
)
);
export type LoginCredentials = v.InferOutput<typeof LoginSchema>;
export type RegistrationPayload = v.InferOutput<typeof RegistrationSchema>;Developers transitioning from Zod often intuitively write v.string().min(5) or v.object().required(). This triggers a runtime TypeError: v.string(...).min is not a function, because v.string() returns a simple schema descriptor object without prototype methods. All validation rules must be composed inside v.pipe().
// ❌ BROKEN: Attempting class method chaining
const BrokenSchema = v.string().minLength(5); // TypeError!
// ✅ RECOMMENDED: Functional composition via pipe()
const CorrectSchema = v.pipe(v.string(), v.minLength(5));pipe() executes actions strictly from left to right. Placing an email() validator before a trim() action means a string like " [email protected] " will be rejected by the regex validator due to leading whitespace before trim() has a chance to sanitize it. Data sanitization actions must always precede format validations.
// ❌ WRONG: email() fails due to spaces before trim() executes
const BadOrder = v.pipe(v.string(), v.email(), v.trim());
// ✅ RECOMMENDED: Sanitize data first, then validate
const GoodOrder = v.pipe(v.string(), v.trim(), v.email());The v.parse() method throws a heavy ValiError exception on validation failure. Calling it inside client form event listeners without try/catch crashes the component render tree and presents users with an unhandled error screen. In client form code, always use v.safeParse().
While import * as v from 'valibot' is tree-shakeable in properly configured modern bundlers, importing unused utilities into client chunks risks accidental inclusion of extra polyfills. Prefer explicit named imports: import { object, string, pipe } from 'valibot'.
Checks the root primitive type (string, number, boolean). If invalid, execution halts immediately.
Input passes to the initial action (e.g., trim() normalization), which yields a sanitized string.
Sanitized data flows through assertions (minLength, email). Failures append to an issues collection.
If issues is empty, safeParse yields a success status with the finalized output payload.