Обратная совместимость

Обратная совместимость — способность схемы корректно обрабатывать данные старых версий приложения, API или базы данных без поломки существующей логики. В контексте Zod это особенно важно при:

  • развитии REST и GraphQL API;
  • миграции frontend/backend контрактов;
  • изменении структуры конфигураций;
  • поддержке старых клиентов;
  • постепенном переходе между версиями схем.

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

Подобные изменения ломают:

  • старые версии frontend;
  • сохранённые JSON-файлы;
  • данные из кеша;
  • внешние интеграции;
  • тестовые фикстуры.

Опциональные поля как основа совместимости

Самый простой способ сохранить обратную совместимость — добавлять новые поля как optional.

const UserSchema = z.object({
  name: z.string(),
  age: z.number().optional(),
});

Теперь обе версии валидны:

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

UserSchema.parse({
  name: "Alex",
  age: 25,
});

Преимущества подхода

  • старые клиенты продолжают работать;
  • постепенное внедрение новых возможностей;
  • отсутствие мгновенных breaking changes;
  • упрощение миграции.

Значения по умолчанию

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

optional

Поле может отсутствовать.

z.number().optional()

Тип:

number | undefined

default

Поле автоматически получает значение.

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

Поддержка нескольких версий API

Иногда одновременно существуют:

  • v1 API;
  • v2 API;
  • legacy-клиенты.

Union-схемы

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() и обратная совместимость

strict() запрещает неизвестные поля.

const Schema = z
  .object({
    username: z.string(),
  })
  .strict();

Теперь старые данные вызовут ошибку:

Schema.parse({
  username: "alex",
  nickname: "neo",
});

Ошибка:

Unrecognized key(s) in object

Когда strict ломает совместимость

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

passthrough() для безопасной эволюции

passthrough() сохраняет неизвестные поля.

const Schema = z
  .object({
    username: z.string(),
  })
  .passthrough();

Пример:

const result = Schema.parse({
  username: "alex",
  nickname: "neo",
});

Результат:

{
  username: "alex",
  nickname: "neo"
}

Это полезно для:

  • middleware;
  • proxy API;
  • gateway-сервисов;
  • постепенных миграций.

strip() как поведение по умолчанию

По умолчанию Zod использует режим strip.

const Schema = z.object({
  username: z.string(),
});

Лишние поля удаляются:

Schema.parse({
  username: "alex",
  nickname: "neo",
});

Результат:

{
  username: "alex"
}

Выбор стратегии обработки неизвестных полей

Режим Поведение Совместимость
strip удаляет лишние поля высокая
passthrough сохраняет лишние поля максимальная
strict вызывает ошибку низкая

nullable() и совместимость

Иногда старые системы отправляют null.

{
  "name": null
}

Стандартная схема:

z.string()

вызовет ошибку.

Для поддержки legacy-данных:

z.string().nullable()

Тип:

string | null

nullish()

nullish() объединяет:

  • optional;
  • nullable.
const Schema = z.object({
  value: z.string().nullish(),
});

Допустимые варианты:

{}
{
  value: null
}
{
  value: "hello"
}

coercion для старых форматов

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

Например:

{
  "age": "25"
}

Стандартная схема:

z.number()

не пройдет.

coerce.number()

const Schema = z.object({
  age: z.coerce.number(),
});

Теперь:

Schema.parse({
  age: "25",
});

Результат:

{
  age: 25
}

Обработка legacy boolean

Часто встречаются значения:

{
  "enabled": "true"
}

или:

{
  "enabled": 1
}

Решение:

const Schema = z.object({
  enabled: z.coerce.boolean(),
});

Совместимость дат

Старые API могут отправлять:

  • timestamp;
  • ISO string;
  • Date;
  • нестандартный формат.

Поддержка нескольких форматов

const DateSchema = z.union([
  z.date(),

  z.string().transform((value) => {
    return new Date(value);
  }),

  z.number().transform((value) => {
    return new Date(value);
  }),
]);

preprocess()

preprocess() особенно полезен для миграций.

const Schema = z.preprocess(
  (value) => {
    if (typeof value === "string") {
      return Number(value);
    }

    return value;
  },
  z.number()
);

Мягкая валидация через safeParse

parse() выбрасывает исключение.

Schema.parse(data);

safeParse() безопаснее для совместимости.

const result = Schema.safeParse(data);

Проверка:

if (!result.success) {
  console.log(result.error);
}

Это позволяет:

  • обрабатывать старые данные без падения;
  • собирать статистику ошибок;
  • постепенно мигрировать систему.

Частичная совместимость через partial()

Старая версия объекта может содержать только часть полей.

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

Для частичной совместимости:

const PartialUserSchema =
  UserSchema.partial();

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


Глубокая 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(),
  });

Централизованный migration layer

Полезный подход:

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

будут успешно обработаны.


Совместимость env-конфигураций

Конфиги особенно чувствительны к breaking changes.

const EnvSchema = z.object({
  PORT: z.coerce.number().default(3000),

  LOG_LEVEL: z.enum([
    "debug",
    "info",
    "warn",
    "error",
  ]).default("info"),
});

Миграция enum

Добавление новых enum-значений обычно безопасно.

z.enum(["user", "admin"]);

z.enum([
  "user",
  "admin",
  "moderator",
]);

Но удаление значений ломает совместимость.


Legacy enum mapping

const RoleSchema = z
  .string()
  .transform((role) => {
    if (role === "superuser") {
      return "admin";
    }

    return role;
  })
  .pipe(
    z.enum([
      "user",
      "admin",
    ])
  );

pipe() в миграциях

pipe() позволяет строить многоэтапные преобразования.

const Schema = z
  .string()
  .transform((value) => value.trim())
  .pipe(
    z.string().min(1)
  );

Совместимость frontend и backend

Главная проблема контрактов:

  • backend обновился;
  • frontend еще нет.

Zod помогает:

const ApiResponseSchema = z.object({
  users: z.array(
    z.object({
      id: z.string(),
      name: z.string(),
      avatar: z.string().optional(),
    })
  ),
});

Новые поля не ломают старый frontend.


Антипаттерны обратной совместимости

Агрессивный strict()

.strict()

ломает старые payload.


Мгновенное удаление полей

Плохой подход:

// было
name

// сразу стало
firstName
lastName

Лучше:

  1. поддерживать оба варианта;
  2. добавить transform;
  3. постепенно удалить legacy-поле.

Отсутствие migration layer

Если логика миграции разбросана по проекту:

  • появляются дубли;
  • растет количество багов;
  • усложняется поддержка.

Рекомендуемая стратегия эволюции схем

Этап 1 — добавление новых полей

.optional()

или:

.default()

Этап 2 — поддержка старого и нового формата

z.union()

или:

transform()

Этап 3 — логирование legacy-данных

safeParse()

с аналитикой ошибок.


Этап 4 — постепенное удаление legacy

После миграции клиентов:

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;
  });

Поддерживаются:

  • старые версии payload;
  • новые версии;
  • лишние поля;
  • строковые числа;
  • разные форматы даты;
  • legacy naming;
  • частичная миграция структуры.