Хроники Valibot: как мы искали безупречные данные в мире JavaScript
- суббота, 24 января 2026 г. в 00:00:09

Если вы когда-нибудь писали фронтенд на TypeScript и получали в проде Cannot read property 'x' of undefined, — добро пожаловать в клуб!
TypeScript спасает нас от сотен ошибок… но только пока код не запущен. Как только он скомпилировался, типы исчезают, и в рантайме вы снова остаетесь один на один с невалидными данными.
И вот тут начинается: меняется API, формы шлют что угодно, аналитика ломает отчёты, а тесты молчат.
В Островке мы попробовали библиотеку Valibot — легковесный runtime-валидатор, который умеет проверять данные на границах контекстов и при этом остаётся дружелюбным к TypeScript.
Под катом рассказываем, почему статической типизации уже недостаточно, чем Valibot отличается от Zod, и как валидатор помогает нам строить более надёжную архитектуру без лишнего кода.
Привет, Хабр! Меня зовут Вадим Царегородцев, я тимлид фронтенд-гильдии в Островке. Сегодня расскажу о нашем опыте с библиотекой Valibot — инструментом, который помогает навести порядок в типах и валидации данных в проектах на JavaScript/TypeScript.
Но начну с фундаментального вопроса: зачем вообще говорить про типы в 2026 году?
Кажется, что тема типизации в JavaScript давно решена — TypeScript победил. При этом любой, кто работал с продакшн-кодом, знает: TS защищает нас только во время разработки. Как только код скомпилировался, все типы исчезают.
И если API внезапно вернул не тот объект — TS просто не заметит. Ошибка придёт в рантайме, где-то в проде, и вы получите классическую cannot read property of undefined.
TypeScript задумывался как язык без runtime overhead, то есть без влияния на выполнение кода. Именно поэтому он не может проверить типы во время работы программы.
Например, у нас есть ручка, которая возвращает нам объект юзера с полями id и name:
type User = {
id: number;
name: string;
};
const user: User = await fetch('api/for/user');Что будет, если поменяется API, бэкендеры что-то перепишут или произойдет ошибка во время выполнения запроса? Мы получим какой-нибудь «cannot read length of property undefined», потому что name, оказывается, может быть undefined.
Где искать решение?
Первая реакция любого инженера — «да я просто добавлю if»:
function log(message) {
if (typeof message === 'string') {
console.log(message);
}
}
Так рождаются километры ручной валидации. Код пухнет, ошибки растут, а типизация превращается в формальность.
Другой вариант — type guards:
function isString(msg: any): msg is string {
return typeof msg === 'string';
}
function log(message: any) {
if (isString(message)) {
console.log(message);
}
}Чуть лаконичнее и проще, но тоже ручной труд.
Кажется, язык не даёт нам нужных возможностей. Что делать? Использовать готовые инструменты для автоматизации.
Позволяет выполнять проверки типов во время выполнения, а не только на этапе компиляции.
interface User {
name: string;
id: number;
}
const test: User = {
name: "testUser",
id: 4
}Например, для интерфейса user мы можем проверить соответствие данных интерфейсу уже в рантайме. TS Runtime выполняет проверки, и вместо кода A мы получаем код B:
const User = x.type(
"User",
x.object(
x.property("name", x.string()),
x.property("id", x.number())
)
);
const test = x.ref(User).assert({
name: "testUser",
id: 4
});Но есть минусы: мы теряем контроль над местами проверок, и проверки типов происходят везде, где вызывается соответствующая функция, что не всегда требуется. Обычно проверки нужны только на границах контекстов — при получении данных из API, парсинге строки и так далее.
Дают ощутимые преимущества по сравнению с библиотеками уровня TS Runtime.
import { Validator } from "jsonschema"
const v = new Validator();
const instance = 4;
const schema = { "type": "number" };
v.validate(instance, schema)Прежде всего — это зрелая экосистема. Под JSON Schema есть огромное количество инструментов: валидаторы, генераторы типов, средства визуализации и документации. Формат основан на JSON, а значит, он прекрасно подходит для сериализации, передачи по сети и интеграции между сервисами. В отличие от специализированных решений, нет зависимости от конкретного рантайма или языка.
Ещё одно важное преимущество — мы сами определяем точки, где хотим выполнять проверку типов. В результате кодовая база не разрастается за счёт вспомогательных конструкций, необходимых библиотекам динамической типизации.
Серьёзный недостаток JSON Schema — многословность. Чтобы провалидировать одно простое число, приходится писать несколько строк схемы. А если нам нужна более сложная структура, объём схемы растёт в геометрической прогрессии. По мере усложнения доменной модели работа с JSON Schema становится всё менее удобной.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "login-app",
"description": "Login User",
"properties": {
"id": {
"description": "id",
"type": "integer"
},
"name": {
"description": "name",
"type": "string"
},
"role": {
"type": "number",
"minimum": 0
}
},
"required": ["id", "name", "role"]
}Существуют инструменты, которые генерируют схемы автоматически — например, конвертируют TypeScript-типы в JSON Schema. Но любой, кто сталкивался с автогенерируемыми сущностями, понимает, что такие схемы сложно читать, отлаживать и тем более корректировать вручную.
На этом моменте мы обращаемся к библиотекам для рантайм-валидации: Zod, Valibot, Yap и прочим. Их идея проста: предоставить удобный синтаксис для декларативного описания схем и при этом автоматически выводить статические типы. Например, Valibot позиционирует себя как «TypeScript-first schema validation tool with static type inference».
Что это означает на практике? Вместо того чтобы сначала писать TypeScript-типы, а потом пытаться привязать к ним валидацию, мы описываем схему прямо в коде библиотеки. Далее — при необходимости — получаем TS-типы с помощью inference. То есть схема становится источником истины: она описывает структуру данных, используется в рантайме для проверки и одновременно служит основой для генерации типов.
По сути, подход напоминает работу с JSON Schema, но с одной важной разницей: мы больше не конвертируем типы в схемы, а наоборот — из схем получаем типы.
Допустим, мы хотим описать сущность пользователя. Импортируем примитивы из Valibot — object, string, number. Каждый из них является схемой сам по себе.
import { object, string, number } from "valibot";
const PersonSchema = object({
name: string(),
id: number(),
});Комбинируя их, мы можем строить более сложные структуры через object({...}).
import { InferOutput } from "valibot";
type Person = InferOutput<typeof PersonSchema>Полученная Person — это пока что только схема, а не тип. Чтобы получить статический тип, мы используем утилиту InferOutput (аналог Infer в Zod). После этого у нас появляется TypePerson, который можно применять в приложении как обычный TypeScript-тип.
Дальше всё просто: в месте, где нам нужно выполнить валидацию, мы вызываем parse, передаём схему и данные. Если данные соответствуют описанной структуре, на выходе мы получаем корректный объект p, уже автоматически приведённый к TypePerson. С ним можно безопасно работать в коде, не боясь неожиданностей.
import { parse } from "valibot";
const p = parse(PersonSchema, {
name: "John Doe",
id: 1
});
// p получит тип Person и весь набор данных из проверяемого объектаЕсли передать в данные лишние свойства, Valibot их проигнорирует. Это ожидаемо: TypeScript использует структурную типизацию, и если схема нашла все обязательные поля, дополнительные атрибуты не считаются ошибкой. Они просто не попадут в итоговый объект.
const p2 = parse(PersonSchema, {
name: "John Doe",
id: 1,
height: 180
});
// height: 180 просто игнорируется, если не нарушает схемуЕсли же ввести некорректные данные — например, передать число вместо строки — Valibot выбросит исключение. Валидация работает строго по описанной схеме.
const p3 = parse(PersonSchema, {
name: 123,
id: 1,
});
// Invalid type: Expected string but received 123Возможно, к настоящему моменту вы уже задумались о паре очевидных вопросов, связанных с использованием Valibot. Попробую на них ответить.
Да, та самая библиотека, о которой говорят на конференциях и пишут массу статей. Но по сути, позиционирование Valibot по отношению к Zod — примерно как Lodash к Underscore.
import { number } from "valibot"; // 1.3k (gzipped: 675)
import { number as zNumber } from "zod"; // 51.8k (gzipped: 12k)Ключевое преимущество Valibot — лёгкость и модульность. Он до 98% легче, чем Zod. Архитектура построена по принципу модульного дизайна: каждая функция-схема лежит в отдельном файле и решает одну конкретную задачу. Благодаря этому при сборке на клиент попадает ровно тот объём кода, который реально использован.
С Zod ситуация другая. Несмотря на именованные импорты, библиотека подтягивает подкапотные подправила целиком (прим. автора: в последних версиях это пофиксили). Импортировав number или string, вы автоматически получаете доступ ко всем методам (email, min, max, length и так далее). И даже если ими не пользуетесь, они всё равно оказываются в бандле. В Valibot же tree-shaking полноценный, без скрытых зависимостей. Это позволяет собирать действительно сложные схемы, включая композицию через pipe, где пайпы можно вкладывать друг в друга бесконечно.
import { number, string } from "zod"; // 51.8k (gzipped: 12k)
const str = string().email().endsWith("example.com")
const num = number().positive().finite();Можно же потребовать от бэкенда OpenAPI/JSON Schema, сгенерировать типы и просто использовать их на фронтенде. И это действительно работает… но только если у вас один бэкенд и один фронтенд.
В 2026 году всё сложнее. Бэкенд для фронтенда чаще всего — это BFF, который агрегирует десятки сервисов. Сервисы возвращают данные в разных форматах, с разными контрактами, разной степенью надёжности. И вместо того чтобы вручную маппить эти ответы в сложную финальную сущность, логичнее подойти с обратной стороны и описать итоговый контракт самостоятельно — через Valibot. Тогда вы можете валидировать данные на входе, независимо от того, сколько сервисов участвует в цепочке.

Тем более что у фронтенда гораздо больше точек входа данных: запросы к бэкенду, формы, параметры URL, локальное состояние, окружение и конфигурации. Везде, где данные приходят «снаружи», нужно доверять только валидации, а не предположениям.
Valibot позволяет сделать эту границу чёткой, лёгкой и типобезопасной.

А теперь перейдём к практическим юзкейсам.
Формы остаются одним из главных мест, где фронтенд сталкивается с валидацией данных. Valibot хорошо подходит для этой задачи благодаря большому набору встроенных примитивов и функций.
Представим, что нам нужно провалидировать форму логина с двумя полями: email и пароль.
import { object, parse, string, pipe, minLength } from "valibot";
const LoginSchema = object({
email: pipe(
string(),
email(),
endsWith('@example.com')),
password: pipe(
string(),
minLength(8));
});Для email можно указать тип string, добавить проверку по регулярному выражению и ограничить домен.
Для пароля — также string и, например, минимальную длину в 8 символов. Очевидно, что требования могут быть гораздо сложнее, поэтому разумно вынести эти проверки в отдельные схемы, чтобы переиспользовать их в разных местах.
const EmailSchema = pipe(string(), email(), endsWith('@example.com'))
const PasswordSchema = pipe(string(), minLength(8))
const LoginSchema = object({
email: EmailSchema,
password: PasswordSchema;
});После того как схемы вынесены, удобно добавить и собственные сообщения об ошибках.
const EmailSchema = pipe(
string("Must be a string"),
email("Wrong email format"),
endsWith('@example.com', "Email must be at example.com"))
const PasswordSchema = pipe(
string("Must be a string"),
minLength(8, "Must be more than 8 symbols"))Valibot подставляет дефолтные текста, но часто выгоднее описать их централизованно: если приложение мультиязычное, то локализация выполняется в одном месте, а все формы автоматически получают корректные тексты для всех языков.
import { messages } from "@/translations"
const EmailSchema = pipe(
string(messages.stringRequired),
email(messages.wrongEmail),
endsWith("@example.com", messages.examplecomRequired))
const PasswordSchema = pipe(
string(messages.stringRequired),
minLength(8, messages.minimum8Symbols))Valibot не ограничивается только встроенными функциями. Есть возможность писать собственные проверки:
Check — принимает функцию, получает на вход данные и возвращает либо успех, либо текст ошибки.
const EmailSchema = pipe(
string(messages.stringRequired),
check(input => input !== input.reverse(), messages.palindromeRequired)
email(messages.wrongEmail),
endsWith("@example.com", messages.examplecomRequired))PartialCheck — полезен при работе с большими объектами, когда нам нужно валидировать только часть полей, например password1 и password2. Функция получает только выбранные поля и выполняет проверку над ними.
Forward — перенаправляет сообщение об ошибке на нужное поле (например, если пароли не совпали, ошибка относится к password2).
const RegisterSchema = pipe(
LoginSchema
forward(
partialCheck(
[["password1"], ["password2"]],
(input) => input.password1 === input.password2,
"The two passwords do not match."
),
["password2"]
)
);Кроме того, почти каждая функция в Valibot имеет асинхронный аналог c постфиксом async. Это полезно, например, когда нужно проверить email на уникальность по базе.
const EmailSchema = pipe(
string(messages.stringRequired),
check(input => input !== input.reverse(), messages.palindromeRequired)
email(messages.wrongEmail),
endsWith("@example.com", messages.examplecomRequired))Как применить всё это на практике?
Проще простого: берём нативную HTML-форму, навешиваем обработчик на submit, извлекаем данные, конвертируем FormData в объект и передаём его в метод parse. Если parse проходит без ошибок — мы получаем гарантированно валидные email и password, которые можно безопасно использовать в дальнейшем.
<form id="login">
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">Login</button>
</form><script>
window.login.addEventListener('submit', function (event) {
event.preventDefault();
const formData = new FormData(event.target);
try {
const { email, password } = parse(
LoginSchema,
Object.fromEntries(formData.entries())
);
// Process safe data
} catch (error) {
// Handle errors
}
});
</script>И что важно — почти без boilerplate. Вместо схемы можно было бы использовать обычный TS-тип, но схема даёт не только типобезопасность, но и бизнес-валидацию. Вы один раз описываете правила, а затем просто вызываете parse — и весь набор требований проверяется автоматически.
const EmailSchema = pipe(
string("Must be a string"),
email("Wrong email format"),
endsWith('@example.com', "Email must be at example.com"))
const PasswordSchema = pipe(
string("Must be a string"),
minLength(8, "Must be more than 8 symbols"))Сами схемы в таком подходе играют роль value object: они определяют форму данных и правила их обработки.
Также помимо parse существует safeParse — по сути, аналог Either.
Он возвращает объект, который содержит и результат, и возможные ошибки.
const result = safeParse(EmailSchema, 'jane@example.com');
if (result.success) {
const email = result.output;
} else {
console.log(result.issues);
}Дальше вы работаете с ним как с обычным значением: проверяете result.success и либо возвращаете данные, либо ошибки. Это удобно для построения UX, где ошибки должны появляться управляемо, без выбрасывания исключений.
Обычными нативными формами возможности Valibot, конечно, не ограничиваются.
Библиотеку можно использовать в любом UI-фреймворке, включая React. Например, если вы работаете с React Hook Form, то для интеграции достаточно воспользоваться Valibot Resolver из отдельного пакета резольверов. Он автоматически пробрасывает ошибки схемы в поля формы.
import { useForm } from 'react-hook-form';
import { valibotResolver } from '@hookform/resolvers/valibot';
...
const { register, handleSubmit } = useForm({
resolver: valibotResolver(LoginSchema),
});
...И не только React Hook Form:
Formik поддерживает валидацию через middleware, которую можно вызвать перед submit.
React Final Form позволяет передавать проверку через validate.
TanStack Form имеет validator.adapter, который работает по тому же принципу, что и резольвер у RHF.
С выходом React 19, в котором фреймворк превращается в полноценный full-stack инструмент, работа с формами стала ещё проще. Теперь можно не вешать обработчики вручную: достаточно указать action и передать серверную функцию.
export default function LoginRoute() {
async function login(formData: FormData) {
"use server";
try {
const { email, password } = parse(
LoginSchema,
Object.fromEntries(formData.entries())
);
// Process safe data
} catch (error) {
// Handle errors
}
}
return (
<form action={login}>
...При сабмите данные автоматически упакуются в FormData и прилетят на сервер. Там вы преобразуете их в объект, валидируете по той же схеме и работаете дальше.
Главный плюс:
Одна и та же схема используется одновременно для клиентской и серверной валидации.
Вы описываете правила один раз — и шарите их между слоями без дублирования логики.

В архитектурах типа Clean Architecture центральную роль играет домен — место, где живут бизнес-сущности и правила их обработки. Это идеальный слой для хранения схем Valibot.
Следующий слой — use cases: правила того, как система должна работать. Логично, что при выполнении use-case нам нужно валидировать входные и выходные данные.

Для наглядности возьмём OOP-подход и спроектируем use case. Начнём с простого интерфейса, который принимает input и возвращает output.
interface UseCase<TInput, TOutput> {
execute(input: TInput): Promise<TOutput>;
}Все use cases, которые наследуют этот интерфейс, должны реализовывать метод execute. Поскольку таких use cases в системе может быть много, и во всех требуется валидация, удобно сделать базовый класс: он реализует шаблон safeExecute, внутри которого будет валидация входных данных, вызов execute и затем валидация результата.
export abstract class SafeUseCase implements UseCase
{
async safeExecute(input: TInput): Promise<TOutput> {
const safeInput = this.inputValidator.validate(input);
const result = await this.execute(safeInput.value);
return this.outputValidator.validate(result);
}
// реализуется в конкретном юз-кейсе
abstract execute(input: TInput): Promise<TOutput>;
}Input-валидатор и output-валидатор передаются извне, потому что каждый use case определяет их сам.
export class LoginUserUseCase extends SafeUseCase {
constructor() {
super(
new SchemaValidator(LoginSchema), // input
new SchemaValidator(UserSchema), // output
);
}
async execute() {
... business logic
}
}Здесь можно использовать schema-validator, обёртку над вызовом valibot.parse. Она нужна, чтобы вписаться в OOP-стиль, но никто не запрещает применять Valibot и по-функциональному. Вообще Valibot сам по себе ближе к функциональному стилю: пайпы, композиции, явное преобразование данных — всё это укладывается в концепцию FP.
У многих наверняка была ситуация, когда аналитик собирает набор событий, передаёт продукт-оунеру, тот правит формулировки, добавляет что-то в Telegram, где-то меняет регистр, где-то название; а разработчик что-то не перенёс, перепутал или забыл. В итоге сидят три-четыре человека и пытаются понять, почему события не сходятся.
Очевидное решение — переложить источник истины в код и контролировать его системой контроля версий. Мы пишем чистую функцию отправки события: она принимает event и транспорт, который отправляет данные.
export const trackEvent = (
event: TrackEvent,
getTransport: () => Transport,
): void => {
const transport = getTransport();
transport.send("event", event);
};Затем создаём функцию валидации: она принимает event и схему.
function validateEvent(
event: TrackEvent;
schema: EventSchema
) {
// throws error if not valid
const validEvent = parse(schema, event);
return validEvent;
}После этого объединяем всё в один pipe: получаем событие, валидируем, затем отправляем.
function PipedTrackEvent(event: TrackEvent) {
return Pipe(event) // sloth-pipe
.to(validateEvent, EventSchema)
.to(trackEvent, getTransport)
.catch(errorPipe(...))
.exec();
}Если валидация падает, мы можем отловить ошибку, залогировать, отправить в Sentry — но мы всегда уверены, что корректные события проходят дальше. А поскольку все события имеют общую структуру, можем использовать одну схему для всех.
Ещё одна область применения Valibot. Например, в тестах нужно проверить структуру ответа:
import { expect, test } from '@playwright/test';
test("GET users/1 returns a user", async ({ request }) => {
const response = await request.get("/user/1");
expect(response.ok()).toBeTruthy();
const user = await response.json();
expect(user).toEqual({
id: 1,
name: "John Doe"
});
});Можно делать это силами Cypress или playwright, сверяя поля вручную.
import { expect, test } from '@playwright/test';
test("get users/1 returns a user", async ({ request }) => {
const response = await request.get('/user/1');
expect(response.ok()).toBeTruthy();
const user = await response.json();
expect(user.id).toBeTruthy();
expect(user.name).toBeTruthy();
});Но структура может меняться от среды к среде — поэтому тесты должны быть гибкими. Здесь помогает идея единого контракта: мы описываем схему на бэкенде, используем её в рантайме и применяем в тестах. Можно написать кастомную команду вроде toMatchSchema, передавать в неё схему и валидировать ответ прямо в тесте.
import { expect, test } from '@playwright/test';
test("GET users/1 returns a user", async ({ request }) => {
const response = await request.get("/user/1");
expect(response.ok()).toBeTruthy();
expect(response).toMatchSchema(UserSchema);
});Интересный момент: когда фронтенд генерирует типы по бэкенду, разработчики вынуждены постоянно тянуть новую схему, пересобирать типы, разбираться в диффах. Это энергозатратно и неудобно. Тесты же минимизируют вовлечённость фронтенда: если контракт сломан — pipeline красный, бэкенд не может выкатиться, пока не исправит. Это куда надёжнее.
В итоге, используя одну и ту же схему Valibot, мы покрываем три слоя: тестирование, фронтенд и бэкенд. Написание тестов упрощается, повышается надёжность и уменьшается количество человеческих ошибок.
Если коротко, Valibot отлично закрывает ту часть работы, которая обычно вызывает больше всего проблем — проверку данных там, где TS уже не помогает. И самое приятное в нём: написал схему один раз — сразу можешь использовать её в формах, в серверных экшенах, внутри юзкейсов, в аналитике и даже в тестах.
Инструмент получается компактный, легко встраивается в любой стек — от чистого JS до React, RHF, Formik и серверных обработчиков. Дополнительно радует тренд в экосистеме: появляется концепция standard-schema, где валидаторы вроде Zod и Valibot становятся почти взаимозаменяемыми — миграции перестают быть «переписыванием всего проекта», а сводятся к выбору синтаксиса и постепенной замене. Понятно, что Zod всё ещё король рынка, но по ощущениям Valibot растёт в правильном направлении: меньше лишнего веса, больше контроля и предсказуемости.
Нужно только помнить, что любой рантайм — это всё равно работа. Он что-то проверяет, что-то трансформирует, значит, что-то тратит. Поэтому бенчмарки — ваш лучший друг, особенно если схема в горячем пути.