Донедавна екосистема валідації в 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: [...] } у разі виявлення помилок.
<FormInput name="email" schema={EmailSchema} />) із вбудованою валідацією без прив'язки до стороннього npm-пакета.Бібліотека перевіряє наявність властивості '~standard' у переданому об'єкті та відповідність версії ('version: 1').
Викликається уніфікований метод валідації вендора. Визначається, чи є результат синхронним значенням чи промісом.
У разі помилки масив issues уже містить стандартизовані поля: рядок повідомлення та сегменти шляху ключа.
У разі успіху повертається властивість 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', 'GBP']),
});
// 2. Схема на Valibot (нативна підтримка ~standard)
const ValibotPayment = v.object({
amountCents: v.pipe(v.number(), v.minValue(1)),
currency: v.picklist(['USD', 'EUR', 'GBP']),
});
// 3. Схема на ArkType 2.0 (нативна підтримка ~standard)
const ArkTypePayment = type({
amountCents: 'number > 0',
currency: '"USD" | "EUR" | "GBP"',
});
// Універсальна бізнес-логіка оформлення замовлення
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.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.
// ❌ ЗЛАМАНО: очікує лише синхронне виконання
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 бути як примітивами (string | number), так і об'єктами вигляду { key: string | number }. Спроба виконати наївний виклик issue.path.join('.') призведе до генерації рядка на кшталт [object Object].[object Object]. Сегменти необхідно нормалізувати перед об'єднанням.
Інженер визначає схему в Zod, Valibot або ArkType. Бібліотека вендора автоматично впроваджує контракт ~standard.
Схема передається у валідатор форми, tRPC-маршрут або фабрику Server Action.
Споживач викликає єдиний метод валідації без знання того, яка саме бібліотека працює під капотом.
Споживач очікує завершення Promise за потреби та аналізує дискримінатор issues.
Якщо помилок немає, очищені дані (value) передаються в цільову бізнес-логіку.