Обратная совместимость — способность схемы корректно обрабатывать данные старых версий приложения, API или базы данных без поломки существующей логики. В контексте Zod это особенно важно при:
Zod предоставляет набор механизмов, позволяющих эволюционировать схемы без жёстких breaking changes.
Типичный пример несовместимости:
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
});
Старые данные:
{
"name": "Alex"
}
После обновления схемы:
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
Теперь старые объекты перестанут проходить валидацию:
UserSchema.parse({
name: "Alex",
});
Ошибка:
Required
Подобные изменения ломают:
Самый простой способ сохранить обратную совместимость — добавлять
новые поля как optional.
const UserSchema = z.object({
name: z.string(),
age: z.number().optional(),
});
Теперь обе версии валидны:
UserSchema.parse({
name: "Alex",
});
UserSchema.parse({
name: "Alex",
age: 25,
});
default() позволяет автоматически заполнять
отсутствующие поля.
const UserSchema = z.object({
name: z.string(),
age: z.number().default(18),
});
Пример:
const result = UserSchema.parse({
name: "Alex",
});
console.log(result);
Результат:
{
name: "Alex",
age: 18
}
optional() от
default()Поле может отсутствовать.
z.number().optional()
Тип:
number | undefined
Поле автоматически получает значение.
z.number().default(18)
Тип:
number
Часто требуется заменить одно поле другим, сохранив поддержку старого формата.
Старая версия:
{
"fullName": "Alex Smith"
}
Новая версия:
{
"firstName": "Alex",
"lastName": "Smith"
}
Схема совместимости:
const UserSchema = z.object({
fullName: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
});
Но такая схема слишком слабая: она позволяет объект вообще без имени.
Более правильный вариант:
const UserSchema = z
.object({
fullName: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
})
.refine(
(data) => {
return (
data.fullName ||
(data.firstName && data.lastName)
);
},
{
message: "Name data is required",
}
);
transform() позволяет конвертировать старый формат в
новый.
const UserSchema = z
.object({
fullName: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
})
.transform((data) => {
if (data.fullName) {
const [firstName, lastName] =
data.fullName.split(" ");
return {
firstName,
lastName,
};
}
return data;
});
Использование:
const user = UserSchema.parse({
fullName: "Alex Smith",
});
console.log(user);
Результат:
{
firstName: "Alex",
lastName: "Smith"
}
Иногда одновременно существуют:
const V1Schema = z.object({
name: z.string(),
});
const V2Schema = z.object({
firstName: z.string(),
lastName: z.string(),
});
const UserSchema = z.union([
V1Schema,
V2Schema,
]);
Теперь принимаются обе структуры.
Если версии отличаются специальным полем:
const V1Schema = z.object({
version: z.literal(1),
name: z.string(),
});
const V2Schema = z.object({
version: z.literal(2),
firstName: z.string(),
lastName: z.string(),
});
Используется discriminatedUnion.
const UserSchema =
z.discriminatedUnion("version", [
V1Schema,
V2Schema,
]);
Преимущества:
Удаление поля — опасная операция.
Старая схема:
const Schema = z.object({
username: z.string(),
nickname: z.string(),
});
Новая схема:
const Schema = z.object({
username: z.string(),
});
Проблема:
Schema.parse({
username: "alex",
nickname: "neo",
});
По умолчанию Zod удалит лишнее поле:
{
username: "alex"
}
Это безопасное поведение для большинства случаев.
strict() запрещает неизвестные поля.
const Schema = z
.object({
username: z.string(),
})
.strict();
Теперь старые данные вызовут ошибку:
Schema.parse({
username: "alex",
nickname: "neo",
});
Ошибка:
Unrecognized key(s) in object
passthrough() сохраняет неизвестные поля.
const Schema = z
.object({
username: z.string(),
})
.passthrough();
Пример:
const result = Schema.parse({
username: "alex",
nickname: "neo",
});
Результат:
{
username: "alex",
nickname: "neo"
}
Это полезно для:
По умолчанию Zod использует режим strip.
const Schema = z.object({
username: z.string(),
});
Лишние поля удаляются:
Schema.parse({
username: "alex",
nickname: "neo",
});
Результат:
{
username: "alex"
}
| Режим | Поведение | Совместимость |
|---|---|---|
| strip | удаляет лишние поля | высокая |
| passthrough | сохраняет лишние поля | максимальная |
| strict | вызывает ошибку | низкая |
Иногда старые системы отправляют null.
{
"name": null
}
Стандартная схема:
z.string()
вызовет ошибку.
Для поддержки legacy-данных:
z.string().nullable()
Тип:
string | null
nullish() объединяет:
optional;nullable.const Schema = z.object({
value: z.string().nullish(),
});
Допустимые варианты:
{}
{
value: null
}
{
value: "hello"
}
Старые системы часто отправляют данные в неверных типах.
Например:
{
"age": "25"
}
Стандартная схема:
z.number()
не пройдет.
const Schema = z.object({
age: z.coerce.number(),
});
Теперь:
Schema.parse({
age: "25",
});
Результат:
{
age: 25
}
Часто встречаются значения:
{
"enabled": "true"
}
или:
{
"enabled": 1
}
Решение:
const Schema = z.object({
enabled: z.coerce.boolean(),
});
Старые API могут отправлять:
const DateSchema = z.union([
z.date(),
z.string().transform((value) => {
return new Date(value);
}),
z.number().transform((value) => {
return new Date(value);
}),
]);
preprocess() особенно полезен для миграций.
const Schema = z.preprocess(
(value) => {
if (typeof value === "string") {
return Number(value);
}
return value;
},
z.number()
);
parse() выбрасывает исключение.
Schema.parse(data);
safeParse() безопаснее для совместимости.
const result = Schema.safeParse(data);
Проверка:
if (!result.success) {
console.log(result.error);
}
Это позволяет:
Старая версия объекта может содержать только часть полей.
const UserSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string(),
});
Для частичной совместимости:
const PartialUserSchema =
UserSchema.partial();
Теперь все поля необязательны.
const Schema = z.object({
profile: z.object({
name: z.string(),
age: z.number(),
}),
});
Стандартный partial():
Schema.partial()
сделает optional только profile.
Для глубокой совместимости:
const DeepPartialSchema =
Schema.deepPartial();
Хорошая практика — хранить отдельные схемы версий.
export const UserV1Schema =
z.object({
name: z.string(),
});
export const UserV2Schema =
z.object({
firstName: z.string(),
lastName: z.string(),
});
Полезный подход:
function migrateUser(data: unknown) {
const parsed =
UserV1Schema.safeParse(data);
if (parsed.success) {
const [firstName, lastName] =
parsed.data.name.split(" ");
return {
firstName,
lastName,
};
}
return UserV2Schema.parse(data);
}
Старые записи БД часто не соответствуют новой схеме.
Пример:
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
role: z.string().default("user"),
});
Даже старые записи:
{
"id": "1",
"email": "test@test.com"
}
будут успешно обработаны.
Конфиги особенно чувствительны к breaking changes.
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
LOG_LEVEL: z.enum([
"debug",
"info",
"warn",
"error",
]).default("info"),
});
Добавление новых enum-значений обычно безопасно.
z.enum(["user", "admin"]);
→
z.enum([
"user",
"admin",
"moderator",
]);
Но удаление значений ломает совместимость.
const RoleSchema = z
.string()
.transform((role) => {
if (role === "superuser") {
return "admin";
}
return role;
})
.pipe(
z.enum([
"user",
"admin",
])
);
pipe() позволяет строить многоэтапные
преобразования.
const Schema = z
.string()
.transform((value) => value.trim())
.pipe(
z.string().min(1)
);
Главная проблема контрактов:
Zod помогает:
const ApiResponseSchema = z.object({
users: z.array(
z.object({
id: z.string(),
name: z.string(),
avatar: z.string().optional(),
})
),
});
Новые поля не ломают старый frontend.
.strict()
ломает старые payload.
Плохой подход:
// было
name
// сразу стало
firstName
lastName
Лучше:
Если логика миграции разбросана по проекту:
.optional()
или:
.default()
z.union()
или:
transform()
safeParse()
с аналитикой ошибок.
После миграции клиентов:
strict()
или удаление старых веток схем.
import { z } from "zod";
const UserSchema = z
.object({
version: z.number().default(1),
fullName: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
age: z.coerce.number().optional(),
role: z
.string()
.default("user"),
createdAt: z.union([
z.date(),
z.string().transform(
(v) => new Date(v)
),
z.number().transform(
(v) => new Date(v)
),
]),
})
.passthrough()
.transform((data) => {
if (
data.fullName &&
!data.firstName
) {
const [firstName, lastName] =
data.fullName.split(" ");
return {
...data,
firstName,
lastName,
};
}
return data;
});
Поддерживаются: