При развитии API, изменении структуры базы данных или обновлении контрактов между сервисами схемы валидации начинают эволюционировать. Поля переименовываются, становятся обязательными, меняют типы или полностью удаляются. Без продуманного механизма версионирования подобные изменения быстро приводят к несовместимости между клиентами и сервером.
Библиотека Zod позволяет строить гибкие стратегии версионирования благодаря:
.extend();.merge();Наиболее простой способ — создание независимых схем для каждой версии структуры.
import { z } from "zod"
const UserV1 = z.object({
id: z.number(),
name: z.string(),
})
const UserV2 = z.object({
id: z.number(),
fullName: z.string(),
email: z.string().email(),
})
Теперь каждая версия имеет собственный контракт.
Проверка:
UserV1.parse({
id: 1,
name: "Alex",
})
UserV2.parse({
id: 1,
fullName: "Alex Smith",
email: "alex@example.com",
})
Подход хорошо работает при:
Недостаток — дублирование общих полей.
.extend()Большинство изменений затрагивает лишь часть структуры. В таких случаях эффективнее использовать расширение базовой схемы.
const BaseUser = z.object({
id: z.number(),
createdAt: z.string(),
})
const UserV1 = BaseUser.extend({
name: z.string(),
})
const UserV2 = UserV1.extend({
email: z.string().email(),
})
Схема версии 2 автоматически содержит:
idcreatedAtnameemailТакой подход:
Типы данных часто меняются по мере развития приложения.
Пример: в первой версии возраст передавался строкой, затем стал числом.
const UserV1 = z.object({
age: z.string(),
})
const UserV2 = z.object({
age: z.number(),
})
При необходимости поддержки обеих версий:
const VersionedUser = z.union([
UserV1,
UserV2,
])
Проверка:
VersionedUser.parse({
age: "25",
})
VersionedUser.parse({
age: 25,
})
Наиболее надёжный способ — явное хранение номера версии в объекте.
versionconst UserV1 = z.object({
version: z.literal(1),
name: z.string(),
})
const UserV2 = z.object({
version: z.literal(2),
fullName: z.string(),
email: z.string(),
})
const UserSchema = z.discriminatedUnion(
"version",
[UserV1, UserV2]
)
Проверка:
UserSchema.parse({
version: 1,
name: "Alex",
})
UserSchema.parse({
version: 2,
fullName: "Alex Smith",
email: "alex@example.com",
})
Преимущества:
Частая проблема — поле становится обязательным только в новой версии.
const ProductV1 = z.object({
title: z.string(),
description: z.string().optional(),
})
const ProductV2 = z.object({
title: z.string(),
description: z.string(),
})
В старой версии поле отсутствует:
ProductV1.parse({
title: "Phone",
})
В новой версии это вызовет ошибку:
ProductV2.parse({
title: "Phone",
})
Иногда новая схема должна принимать старые данные.
.optional()const ProductSchema = z.object({
title: z.string(),
description: z.string().optional(),
})
const ProductSchema = z.object({
title: z.string(),
description: z.string().default(""),
})
Теперь старые объекты автоматически дополняются:
const result = ProductSchema.parse({
title: "Laptop",
})
console.log(result)
Результат:
{
title: "Laptop",
description: ""
}
.transform()Zod позволяет преобразовывать устаревшие структуры в новые.
const UserV1 = z.object({
name: z.string(),
})
const MigratedUser = UserV1.transform((data) => {
return {
fullName: data.name,
}
})
Использование:
const result = MigratedUser.parse({
name: "Alex",
})
Результат:
{
fullName: "Alex"
}
const UserV1 = z.object({
version: z.literal(1),
name: z.string(),
})
const UserV2 = z.object({
version: z.literal(2),
fullName: z.string(),
email: z.string().email(),
})
const MigratedSchema = UserV1.transform((data) => {
return {
version: 2,
fullName: data.name,
email: "unknown@example.com",
}
})
.union()Иногда система должна одновременно принимать несколько поколений данных.
const ApiPayload = z.union([
UserV1,
UserV2,
])
Подобная стратегия используется:
Поле может считаться устаревшим, но ещё поддерживаться.
const UserSchema = z.object({
name: z.string().optional(),
fullName: z.string().optional(),
})
const UserSchema = z.object({
name: z.string().optional(),
fullName: z.string().optional(),
}).refine(
(data) => data.name || data.fullName,
{
message: "Требуется name или fullName",
}
)
После завершения периода совместимости старые поля можно запретить.
const UserSchema = z.object({
fullName: z.string(),
}).strict()
Теперь объект:
{
fullName: "Alex",
name: "Old"
}
вызовет ошибку.
.strict()Запрещает лишние поля.
z.object({
name: z.string(),
}).strict()
.passthrough()Сохраняет неизвестные поля.
z.object({
name: z.string(),
}).passthrough()
Подходит для:
.strip()Удаляет лишние поля.
z.object({
name: z.string(),
}).strip()
const ResponseV1 = z.object({
data: z.object({
name: z.string(),
}),
})
const ResponseV2 = z.object({
data: z.object({
fullName: z.string(),
email: z.string(),
}),
meta: z.object({
timestamp: z.number(),
}),
})
const ApiResponse = z.union([
ResponseV1,
ResponseV2,
])
Конфигурации особенно чувствительны к изменению структуры.
const ConfigV1 = z.object({
host: z.string(),
port: z.number(),
})
const ConfigV2 = z.object({
server: z.object({
host: z.string(),
port: z.number(),
}),
})
const MigratedConfig = ConfigV1.transform((config) => {
return {
server: {
host: config.host,
port: config.port,
},
}
})
При большом количестве версий полезно строить последовательные преобразования.
const migrateV1toV2 = (data: any) => {
return {
version: 2,
fullName: data.name,
}
}
const migrateV2toV3 = (data: any) => {
return {
version: 3,
fullName: data.fullName,
active: true,
}
}
function migrate(data: any) {
if (data.version === 1) {
data = migrateV1toV2(data)
}
if (data.version === 2) {
data = migrateV2toV3(data)
}
return data
}
.merge()Схемы можно собирать из модулей.
const BaseSchema = z.object({
id: z.number(),
})
const AuditSchema = z.object({
createdAt: z.string(),
})
const EntityV2 = BaseSchema.merge(AuditSchema)
Поле может стать nullable.
const UserV1 = z.object({
middleName: z.string(),
})
const UserV2 = z.object({
middleName: z.string().nullable(),
})
При PATCH-запросах структура версии часто отличается от полной модели.
const UserSchema = z.object({
name: z.string(),
email: z.string(),
})
const PatchUserSchema =
UserSchema.partial()
Теперь все поля необязательны.
const TagsV1 = z.array(z.string())
const TagsV2 = z.array(
z.object({
id: z.number(),
title: z.string(),
})
)
const TagsSchema = z.union([
TagsV1,
TagsV2,
])
При удалении функциональности важно не ломать старые данные мгновенно.
const LegacySchema = z.object({
oldField: z.string().optional(),
newField: z.string(),
})
const Schema = LegacySchema.transform((data) => {
return {
newField: data.newField || data.oldField,
}
})
.superRefine() для сложной совместимости.superRefine() позволяет реализовывать правила,
зависящие от версии.
const Schema = z.object({
version: z.number(),
email: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.version >= 2 && !data.email) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "email обязателен для version >= 2",
})
}
})
Zod автоматически генерирует типы.
type UserV1Type = z.infer<typeof UserV1>
type UserV2Type = z.infer<typeof UserV2>
Для объединённых схем:
type User =
z.infer<typeof UserSchema>
TypeScript корректно определяет поля в зависимости от
version.
function printUser(user: User) {
if (user.version === 1) {
console.log(user.name)
}
if (user.version === 2) {
console.log(user.fullName)
}
}
Распространённая структура проекта:
schemas/
├── user/
│ ├── v1.ts
│ ├── v2.ts
│ ├── migrations.ts
│ └── index.ts
export * from "./v1"
export * from "./v2"
export * from "./migrations"
При большом количестве сущностей полезен реестр версий.
const schemaRegistry = {
user: {
1: UserV1,
2: UserV2,
},
}
Получение схемы:
const schema =
schemaRegistry.user[2]
Плохо:
const User = z.object({
age: z.number(),
})
После обновления:
const User = z.object({
age: z.string(),
})
Подобное изменение ломает совместимость.
Плохо:
z.union([
schema1,
schema2,
])
Если структуры похожи, возможны неоднозначности.
Лучше:
z.discriminatedUnion("version", [
schema1,
schema2,
])
Резкое удаление deprecated-полей приводит к поломке старых клиентов.
Более безопасный процесс:
const UserV1 = z.object({
version: z.literal(1),
name: z.string(),
})
const UserV2 = z.object({
version: z.literal(2),
fullName: z.string(),
})
const UserV3 = z.object({
version: z.literal(3),
fullName: z.string(),
email: z.string().email(),
})
const UserSchema = z.discriminatedUnion(
"version",
[UserV1, UserV2, UserV3]
)
function migrateUser(data: any) {
switch (data.version) {
case 1:
return {
version: 3,
fullName: data.name,
email: "unknown@example.com",
}
case 2:
return {
version: 3,
fullName: data.fullName,
email: "unknown@example.com",
}
case 3:
return data
}
}
const migrated = migrateUser(oldData)
const valid =
UserV3.parse(migrated)