Стратегии постепенного внедрения

В крупных JavaScript- и TypeScript-проектах переход на строгую валидацию данных редко выполняется одномоментно. Обычно кодовая база уже содержит:

  • десятки API-интеграций;
  • неявные структуры данных;
  • ручные проверки;
  • устаревшие типы;
  • смешение runtime- и compile-time-валидации.

Постепенное внедрение позволяет:

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

Локальная интеграция Zod

Наиболее безопасная стратегия — внедрение Zod только в новые модули.

Валидация новых API

import { z } from "zod"

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

async function loadUser(id: number) {
  const response = await fetch(`/api/users/${id}`)
  const json = await response.json()

  return UserSchema.parse(json)
}

Старый код продолжает работать без изменений, а новые участки получают:

  • runtime-проверку;
  • безопасную типизацию;
  • предсказуемые ошибки.

Постепенная замена ручных проверок

Исходный код без Zod

function createUser(data: any) {
  if (!data.email) {
    throw new Error("Email required")
  }

  if (typeof data.age !== "number") {
    throw new Error("Age invalid")
  }

  return {
    email: data.email,
    age: data.age
  }
}

Подобные проверки быстро становятся:

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

Переход на Zod

import { z } from "zod"

const CreateUserSchema = z.object({
  email: z.string().email(),
  age: z.number()
})

function createUser(data: unknown) {
  const validated = CreateUserSchema.parse(data)

  return validated
}

Использование safeParse на ранних этапах

Во время миграции приложение может содержать большое количество некорректных данных. Использование parse() иногда приводит к массовым исключениям.

В таких случаях предпочтительнее safeParse().

Безопасная валидация

const result = CreateUserSchema.safeParse(data)

if (!result.success) {
  console.error(result.error)

  return null
}

return result.data
}

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

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

Граница доверия данных

Одна из самых эффективных стратегий внедрения — валидация только внешних источников данных.

Основные точки входа

HTTP API

app.post("/users", (req, res) => {
  const body = UserSchema.parse(req.body)

  saveUser(body)
})

Query-параметры

const QuerySchema = z.object({
  page: z.coerce.number().default(1)
})

ENV-переменные

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number()
})

const env = EnvSchema.parse(process.env)

WebSocket-события

const EventSchema = z.object({
  type: z.string(),
  payload: z.any()
})

Внедрение через адаптеры

В legacy-проектах часто невозможно изменить существующие функции.

В таких случаях используется слой адаптации.

Старый код

function saveUser(user: any) {
  database.insert(user)
}

Адаптер с Zod

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

function validatedSaveUser(input: unknown) {
  const user = UserSchema.parse(input)

  return saveUser(user)
}

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

  • не ломает старый API;
  • обеспечивает постепенный переход;
  • локализует валидацию.

Стратегия “снаружи внутрь”

Один из наиболее эффективных подходов.

Этап 1 — внешние данные

Валидируются:

  • API;
  • формы;
  • события;
  • переменные окружения.

Этап 2 — сервисный слой

Схемы начинают использоваться внутри бизнес-логики.

Этап 3 — внутренние модели

Zod становится единым источником типов.


Использование infer вместо ручных интерфейсов

Во многих проектах уже существуют интерфейсы TypeScript.

Старый подход

interface User {
  id: number
  email: string
}

Проблема — отсутствие runtime-проверки.


Переход через infer

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

type User = z.infer<typeof UserSchema>

Теперь:

  • схема проверяет данные;
  • тип автоматически синхронизирован;
  • исчезает дублирование.

Параллельное существование интерфейсов и схем

Полная миграция может занимать месяцы.

Допустимо временное сосуществование.

Пример

interface LegacyUser {
  id: number
  email: string
}
const UserSchema = z.object({
  id: z.number(),
  email: z.string()
})

На промежуточном этапе:

  • новые модули используют Zod;
  • старые продолжают работать через interfaces/types.

Внедрение через DTO

При работе с backend-архитектурой полезно валидировать DTO.

DTO-схема

const CreatePostDto = z.object({
  title: z.string().min(3),
  content: z.string(),
  tags: z.array(z.string())
})

Использование

function createPost(input: unknown) {
  const dto = CreatePostDto.parse(input)

  return service.create(dto)
}

Постепенная типизация unknown

Во многих старых проектах активно используется any.

Zod помогает постепенно переходить к unknown.

Небезопасный код

function process(data: any) {
  return data.user.name
}

Промежуточный этап

function process(data: unknown) {
  const parsed = UserSchema.parse(data)

  return parsed.name
}

Использование partial во время миграции

Иногда невозможно сразу описать полную схему объекта.

В таких случаях применяется partial().

Частичная схема

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

const PartialUserSchema = UserSchema.partial()

Что это дает

Можно валидировать только известные поля:

PartialUserSchema.parse({
  email: "admin@example.com"
})

Стратегия “мягкой” валидации

Во время миграции часто важно не блокировать работу приложения.

Логирование ошибок вместо исключений

const result = UserSchema.safeParse(data)

if (!result.success) {
  logger.warn(result.error)

  return data
}

Подход особенно полезен:

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

Постепенное ужесточение схем

Сначала схема может быть максимально мягкой.

Начальный вариант

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

Более строгий вариант

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

Использование passthrough для legacy-данных

Старые API часто возвращают лишние поля.

По умолчанию Zod их удаляет.

Проблема

const UserSchema = z.object({
  id: z.number()
})
UserSchema.parse({
  id: 1,
  legacyField: true
})

Поле legacyField будет отброшено.


Решение

const UserSchema = z.object({
  id: z.number()
}).passthrough()

Теперь дополнительные поля сохраняются.


Использование strip и strict

Во время миграции важно контролировать неизвестные свойства.

strip

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

z.object({
  id: z.number()
}).strip()

passthrough

Сохраняет лишние поля.

z.object({
  id: z.number()
}).passthrough()

strict

Вызывает ошибку при наличии лишних полей.

z.object({
  id: z.number()
}).strict()

Инкрементальная миграция форм

Формы — один из лучших кандидатов для внедрения Zod.

React Hook Form + Zod

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
})
useForm({
  resolver: zodResolver(schema)
})

Централизация схем

На поздних этапах миграции схемы начинают храниться централизованно.

Структура проекта

src/
  schemas/
    user.schema.ts
    post.schema.ts
    auth.schema.ts

Разделение схем по слоям

В больших проектах полезно разделять:

  • database schemas;
  • API schemas;
  • form schemas;
  • internal schemas.

Пример

schemas/
  api/
  db/
  forms/
  internal/

Переиспользование существующих схем

Одна из ключевых стратегий масштабирования.

Базовая схема

const BaseUserSchema = z.object({
  id: z.number(),
  email: z.string().email()
})

Расширение

const AdminSchema = BaseUserSchema.extend({
  role: z.literal("admin")
})

Использование pick и omit

Во время миграции часто нужны разные представления объекта.

pick

const PublicUserSchema = UserSchema.pick({
  id: true,
  email: true
})

omit

const SafeUserSchema = UserSchema.omit({
  password: true
})

Стратегия тестового покрытия

Перед масштабной миграцией полезно покрывать схемы тестами.

Проверка валидных данных

expect(() => {
  UserSchema.parse(validData)
}).not.toThrow()

Проверка ошибок

expect(() => {
  UserSchema.parse(invalidData)
}).toThrow()

Миграция backend-контрактов

Zod особенно эффективен как единый контракт между frontend и backend.

Общая схема

export const UserSchema = z.object({
  id: z.number(),
  email: z.string().email()
})

Backend

app.get("/users/:id", () => {
  return UserSchema.parse(data)
})

Frontend

const user = UserSchema.parse(response)

Постепенное внедрение в monorepo

В monorepo-системах миграция обычно выполняется пакетами.

Возможная последовательность

  1. Общие утилиты.
  2. API-контракты.
  3. Backend.
  4. Frontend.
  5. Внутренние сервисы.

Стратегия обратной совместимости

Во время миграции важно учитывать старые форматы данных.

Union-схемы

const OldUserSchema = z.object({
  name: z.string()
})

const NewUserSchema = z.object({
  firstName: z.string(),
  lastName: z.string()
})

const CompatibleSchema = z.union([
  OldUserSchema,
  NewUserSchema
])

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

При долгосрочной поддержке API полезно хранить версии схем.

Пример

schemas/
  v1/
  v2/
  v3/

Использование transform при миграции

Zod может не только валидировать, но и преобразовывать данные.

Нормализация

const UserSchema = z.object({
  email: z.string()
}).transform(data => ({
  ...data,
  email: data.email.toLowerCase()
}))

Использование preprocess

Подходит для устаревших форматов.

Пример

const NumberSchema = z.preprocess(
  value => Number(value),
  z.number()
)

Метрики внедрения

Во время постепенной миграции полезно отслеживать:

  • количество схем;
  • покрытие API;
  • количество any;
  • число ошибок валидации;
  • процент модулей с runtime-проверкой.

Типичные ошибки постепенной миграции

Попытка переписать всё сразу

Часто приводит к:

  • огромным pull request;
  • конфликтам;
  • нестабильности.

Избыточная строгость на старте

Слишком строгие схемы ломают legacy-данные.


Использование any вместе с parse

const data: any = UserSchema.parse(input)

Так теряется смысл типизации.


Дублирование схем и интерфейсов

interface User {
  id: number
}
const UserSchema = z.object({
  id: z.number()
})

Если оба определения поддерживаются вручную, возникает рассинхронизация.


Практическая стратегия миграции большого проекта

Этап 1

Валидация:

  • ENV;
  • HTTP;
  • forms.

Этап 2

Удаление any.

Этап 3

Создание shared schemas.

Этап 4

Интеграция во все API-контракты.

Этап 5

Переход на schema-first архитектуру.


Schema-first подход

На позднем этапе Zod становится главным источником структуры данных.

Схема

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

Тип

type User = z.infer<typeof UserSchema>

Runtime

UserSchema.parse(data)

Документация

Схема одновременно описывает:

  • структуру;
  • типизацию;
  • runtime-валидацию;
  • контракт API;
  • ограничения данных.