Версионирование схем

При развитии 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 автоматически содержит:

  • id
  • createdAt
  • name
  • email

Такой подход:

  • уменьшает дублирование;
  • сохраняет единый источник истины;
  • облегчает поддержку.

Изменение типов полей между версиями

Типы данных часто меняются по мере развития приложения.

Пример: в первой версии возраст передавался строкой, затем стал числом.

Версия 1

const UserV1 = z.object({
  age: z.string(),
})

Версия 2

const UserV2 = z.object({
  age: z.number(),
})

При необходимости поддержки обеих версий:

const VersionedUser = z.union([
  UserV1,
  UserV2,
])

Проверка:

VersionedUser.parse({
  age: "25",
})

VersionedUser.parse({
  age: 25,
})

Использование дискриминаторов версий

Наиболее надёжный способ — явное хранение номера версии в объекте.

Схемы с полем version

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(),
})

Дискриминированное объединение

const UserSchema = z.discriminatedUnion(
  "version",
  [UserV1, UserV2]
)

Проверка:

UserSchema.parse({
  version: 1,
  name: "Alex",
})

UserSchema.parse({
  version: 2,
  fullName: "Alex Smith",
  email: "alex@example.com",
})

Преимущества:

  • высокая производительность;
  • точная типизация TypeScript;
  • понятная структура;
  • отсутствие неоднозначности.

Эволюция обязательных полей

Частая проблема — поле становится обязательным только в новой версии.

Версия 1

const ProductV1 = z.object({
  title: z.string(),
  description: z.string().optional(),
})

Версия 2

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"
}

Полноценная миграция между версиями

Версия 1

const UserV1 = z.object({
  version: z.literal(1),
  name: z.string(),
})

Версия 2

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,
])

Подобная стратегия используется:

  • в API с длительной поддержкой клиентов;
  • при постепенном обновлении мобильных приложений;
  • во время миграции микросервисов.

Обработка deprecated-полей

Поле может считаться устаревшим, но ещё поддерживаться.

Поддержка старого и нового имени

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()

Подходит для:

  • постепенной миграции;
  • совместимости со старыми клиентами;
  • промежуточных версий API.

.strip()

Удаляет лишние поля.

z.object({
  name: z.string(),
}).strip()

Версионирование API-ответов

Версия 1

const ResponseV1 = z.object({
  data: z.object({
    name: z.string(),
  }),
})

Версия 2

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(),
})

Версия 2

const EntityV2 = BaseSchema.merge(AuditSchema)

Работа с nullable-полями между версиями

Поле может стать nullable.

Версия 1

const UserV1 = z.object({
  middleName: z.string(),
})

Версия 2

const UserV2 = z.object({
  middleName: z.string().nullable(),
})

Частичное обновление схем

При PATCH-запросах структура версии часто отличается от полной модели.

Основная схема

const UserSchema = z.object({
  name: z.string(),
  email: z.string(),
})

PATCH-схема

const PatchUserSchema =
  UserSchema.partial()

Теперь все поля необязательны.


Версионирование массивов

Версия 1

const TagsV1 = z.array(z.string())

Версия 2

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",
    })
  }
})

Типизация версий в TypeScript

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

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-полей приводит к поломке старых клиентов.

Более безопасный процесс:

  1. Добавление нового поля.
  2. Поддержка обоих вариантов.
  3. Предупреждение о deprecated.
  4. Миграция клиентов.
  5. Удаление старого поля.

Практический пример полноценного жизненного цикла

Версия 1

const UserV1 = z.object({
  version: z.literal(1),
  name: z.string(),
})

Версия 2

const UserV2 = z.object({
  version: z.literal(2),
  fullName: z.string(),
})

Версия 3

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)