До недавнего времени экосистема валидации в TypeScript находилась в состоянии глубокой архитектурной фрагментации. Каждая популярная библиотека предлагала собственный закрытый API:
.safeParse(data) и внутренними полями _input / _output.v.safeParse(schema, data) и TypeScript-утилиты InferInput / InferOutput.type(data) с проверкой instanceof type.errors.Ajv или TypeCompiler.Для разработчиков инструментальных библиотек (React Hook Form, TanStack Form, tRPC, Hono, Astro, Next.js Server Actions) это создавало экспоненциальную проблему поддержки адаптеров ($M \times N$ комбинаций). Создателям библиотек форм приходилось разрабатывать и обновлять десятки разрозненных пакетов: @hookform/resolvers/zod, @hookform/resolvers/valibot, @hookform/resolvers/yup, @trpc/server/adapters/arktype. Любое минорное обновление сигнатур ошибок у одного вендора ломало всю цепочку интеграций.
В 2024 году создатели ключевых библиотек валидации — Колин Макдоннелл (Zod), Фабиан Кук (Valibot) и Дэвид Бласс (ArkType) при активной поддержке авторов TanStack и tRPC — объединили усилия и разработали открытую спецификацию Standard Schema (@standard-schema/spec).
Standard Schema — это легковесный, вендор-нейтральный интерфейс контракта валидации, принятый всеми ключевыми инструментами 2025–2026 годов. Спецификация определяет:
~standard.~standard.validate(value).Благодаря Standard Schema любая библиотека или фреймворк может принимать валидационную схему любого вендора без установки промежуточных резолверов и плагинов (Zero-Adapter Architecture).
Вместо разработки отдельного адаптера под каждого вендора фреймворк взаимодействует с единой шиной спецификации ~standard:
+-------------------------------------------------------------------------+
| ФРАГМЕНТАЦИЯ ЭКОСИСТЕМЫ (Прошлое: M x N адаптеров) |
+-------------------------------------------------------------------------+
| React Hook Form ===> [@hookform/resolvers/zod] ===> Zod |
| TanStack Form ===> [@tanstack/valibot-adapter] ===> Valibot |
| tRPC Server ===> [@trpc/arktype-resolver] ===> ArkType |
| * Десятки несовместимых адаптеров, рассинхронизация версий! |
+-------------------------------------------------------------------------+
| |
| АРХИТЕКТУРА STANDARD SCHEMA (Настоящее: 1 контракт) |
+-------------------------------------------------------------------------+
| React Hook Form ---\ |
| TanStack Form -----> [ StandardSchemaV1: ~standard.validate() ] |
| tRPC / Hono / Actions -/ | |
| +---> Zod v4 (~standard) |
| +---> Valibot (~standard) |
| +---> ArkType (~standard) |
| * Полная взаимозаменяемость библиотек валидации "из коробки"! |
+-------------------------------------------------------------------------+Каждая схема содержит скрытое служебное свойство ~standard. Метод ~standard.validate() принимает сырое значение и возвращает детерминированный объект-дискриминатор: либо { value: Output } при успехе, либо { issues: [...] } при сбое.
Создается схема в Zod, Valibot или ArkType. Вендор автоматически инжектирует контракт ~standard.
Схема передается в библиотеку форм, tRPC маршрут или функцию создания Server Action.
Потребитель вызывает единый метод валидации без знания того, какая библиотека находится под капотом.
Потребитель нормализует Promise-результат и проверяет наличие дискриминатора issues.
При отсутствии проблем очищенные данные (value) передаются в целевой бизнес-код.
Какую фундаментальную проблему решает спецификация Standard Schema и зарезервированное свойство ~standard в библиотеках валидации?
<FormInput name="email" schema={EmailSchema} />) с валидацией без привязки к конкретному пакету.Библиотека проверяет наличие свойства '~standard' в переданном объекте и соответствие версии ('version: 1').
Вызывается унифицированный метод валидации вендора. Проверяется, является ли результат промисом.
В случае ошибки массив issues уже содержит стандартизированные поля: message, path (сегменты ключей).
При успехе возвращается свойство value, строго соответствующее типу output из типов схемы.
@standard-schema/spec#Ниже приведена строгая типизация спецификации StandardSchemaV1 без использования типа any:
// Сегмент пути ошибки: имя свойства объекта или индекс массива
export type StandardPathSegment = string | number | symbol;
export interface StandardPath {
readonly key: StandardPathSegment;
}
// Стандартизированное описание ошибки валидации
export interface StandardIssue {
readonly message: string;
readonly path?: ReadonlyArray<StandardPathSegment | StandardPath>;
}
// Результат успешной валидации
export interface StandardSuccessResult<TOutput> {
readonly value: TOutput;
readonly issues?: undefined;
}
// Результат валидации с ошибками
export interface StandardFailureResult {
readonly issues: ReadonlyArray<StandardIssue>;
readonly value?: undefined;
}
export type StandardResult<TOutput> =
| StandardSuccessResult<TOutput>
| StandardFailureResult;
// Главный интерфейс спецификации
export interface StandardSchemaV1<TInput = unknown, TOutput = TInput> {
readonly '~standard': {
readonly version: 1;
readonly vendor: string;
readonly validate: (
value: unknown
) => StandardResult<TOutput> | Promise<StandardResult<TOutput>>;
readonly types?: {
readonly input: TInput;
readonly output: TOutput;
};
};
}
// Вспомогательные утилиты извлечения типов
export type InferStandardInput<TSchema extends StandardSchemaV1> =
NonNullable<TSchema['~standard']['types']>['input'];
export type InferStandardOutput<TSchema extends StandardSchemaV1> =
NonNullable<TSchema['~standard']['types']>['output'];Разработаем функцию, способную валидировать входящие данные любой схемой, поддерживающей спецификацию:
import {
StandardSchemaV1,
StandardResult,
InferStandardOutput,
} from './standard-schema-spec';
// Защитный type guard для проверки поддержки спецификации
export function isStandardSchema(value: unknown): value is StandardSchemaV1 {
return (
typeof value === 'object' &&
value !== null &&
'~standard' in value &&
typeof (value as Record<string, unknown>)['~standard'] === 'object' &&
(value as Record<string, unknown>)['~standard'] !== null &&
(value as { '~standard': { version: unknown } })['~standard'].version === 1
);
}
// Универсальная функция валидации, поддерживающая асинхронные и синхронные схемы
export async function validateUniversal<TSchema extends StandardSchemaV1>(
schema: TSchema,
input: unknown
): Promise<InferStandardOutput<TSchema>> {
if (!isStandardSchema(schema)) {
throw new Error('Переданный объект схемы не поддерживает спецификацию Standard Schema v1');
}
// Вызов унифицированного метода валидации
let result = schema['~standard'].validate(input);
// Разрешение промиса, если валидация асинхронная
if (result instanceof Promise) {
result = await result;
}
if (result.issues && result.issues.length > 0) {
const formattedErrors = result.issues
.map((issue) => {
const path = issue.path
?.map((p) => (typeof p === 'object' ? String(p.key) : String(p)))
.join('.');
return path ? `${path}: ${issue.message}` : issue.message;
})
.join('; ');
throw new Error(`Ошибка валидации данных: ${formattedErrors}`);
}
return result.value as InferStandardOutput<TSchema>;
}Благодаря Standard Schema три разные библиотеки валидации работают через единый пайплайн:
import { z } from 'zod';
import * as v from 'valibot';
import { type } from 'arktype';
import { validateUniversal } from './universal-validator';
// 1. Схема на Zod v4 (нативная поддержка ~standard)
const ZodPayment = z.object({
amountCents: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'RUB']),
});
// 2. Схема на Valibot (нативная поддержка ~standard)
const ValibotPayment = v.object({
amountCents: v.pipe(v.number(), v.minValue(1)),
currency: v.picklist(['USD', 'EUR', 'RUB']),
});
// 3. Схема на ArkType 2.0 (нативная поддержка ~standard)
const ArkTypePayment = type({
amountCents: 'number > 0',
currency: '"USD" | "EUR" | "RUB"',
});
// Универсальная бизнес-логика платежного шлюза
async function processCheckout() {
const rawPayload = { amountCents: 5000, currency: 'EUR' };
// Все три вызова используют одну и ту же функцию validateUniversal!
const res1 = await validateUniversal(ZodPayment, rawPayload);
const res2 = await validateUniversal(ValibotPayment, rawPayload);
const res3 = await validateUniversal(ArkTypePayment, rawPayload);
console.log('Успешная валидация всеми вендорами:', res1.currency, res2.amountCents, res3.amountCents);
}Разработаем типобезопасную фабрику создания Server Actions для React 19 / Next.js, принимающую схему любого вендора:
import {
StandardSchemaV1,
InferStandardOutput,
InferStandardInput,
} from './standard-schema-spec';
export interface ActionResponse<TData> {
isSuccess: boolean;
data?: TData;
fieldErrors?: Record<string, string[]>;
serverError?: string;
}
export function createUniversalAction<
TSchema extends StandardSchemaV1,
TResult
>(
schema: TSchema,
actionHandler: (data: InferStandardOutput<TSchema>) => Promise<TResult>
) {
return async (
rawInput: InferStandardInput<TSchema>
): Promise<ActionResponse<TResult>> => {
// 1. Проверка контракта спецификации
const standard = schema['~standard'];
if (!standard || standard.version !== 1) {
return {
isSuccess: false,
serverError: 'Несовместимая версия спецификации валидации',
};
}
// 2. Валидация входных данных
let validation = standard.validate(rawInput);
if (validation instanceof Promise) {
validation = await validation;
}
// 3. Обработка ошибок валидации
if (validation.issues && validation.issues.length > 0) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of validation.issues) {
const pathKey = issue.path
?.map((segment) => (typeof segment === 'object' ? String(segment.key) : String(segment)))
.join('.') || '_root';
if (!fieldErrors[pathKey]) {
fieldErrors[pathKey] = [];
}
fieldErrors[pathKey].push(issue.message);
}
return {
isSuccess: false,
fieldErrors,
};
}
// 4. Выполнение серверной логики
try {
const result = await actionHandler(validation.value as InferStandardOutput<TSchema>);
return {
isSuccess: true,
data: result,
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Внутренний сбой сервера';
return {
isSuccess: false,
serverError: msg,
};
}
};
}Почему при вызове schema['~standard'].validate(input) в универсальном рантайме критически важно проверять result instanceof Promise?
Разработчик создает "универсальную" функцию, но внутри пишет schema.parse(data) или v.safeParse(schema, data). Это мгновенно привязывает код к конкретной библиотеке и вызывает ошибку TypeError: schema.parse is not a function, если клиент передает схему от другого вендора (например, Valibot или ArkType). В универсальном коде допустимо обращаться только к свойству schema['~standard'].validate().
Метод validate() может возвращать синхронный объект StandardResult либо промис Promise<StandardResult>. Если в коде отсутствует проверка instanceof Promise или ключевое слово await, валидация асинхронных схем завершится попыткой прочитать свойство issues прямо из неразрешенного объекта Promise.
// ❌ СЛОМАНО: не учитывает, что validate() может вернуть Promise
const result = schema['~standard'].validate(input);
if (result.issues) { /* result.issues будет undefined, если result — это Promise! */ }
// ✅ ПРАВИЛЬНО: поддержка обоих режимов
let result = schema['~standard'].validate(input);
if (result instanceof Promise) {
result = await result;
}Если схема содержит трансформации (например, приведение строки к числу z.coerce.number() или v.trim()), тип входных данных input и выходных данных output различаются. Использование InferStandardInput вместо InferStandardOutput в типе обработчика приведет к передаче непроверенных или ненормализованных данных в базу данных.
Спецификация разрешает сегментам пути path быть как строками/числами, так и объектами с полем { key: string | number }. Попытка сделать наивный issue.path.join('.') приведет к выводу строки [object Object].[object Object]. Сегменты необходимо нормализовать.