Покрытие edge cases

Edge cases — это редкие, нестандартные или пограничные сценарии, при которых данные формально соответствуют ожидаемому типу, но всё равно оказываются некорректными с точки зрения бизнес-логики, структуры API или поведения приложения.

Библиотека Zod предоставляет широкий набор инструментов для обработки подобных случаев: от кастомных проверок до сложных трансформаций и управления ошибками.


Проблема undefined, null и отсутствующих полей

В JavaScript существуют три разных состояния:

const obj = {
  a: undefined,
  b: null
}

И отдельный случай:

const obj = {}

С точки зрения Zod это разные сценарии.


optional()

Разрешает отсутствие поля или undefined.

import { z } from "zod"

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

Допустимо:

{}
{
  name: undefined
}
{
  name: "Alex"
}

Недопустимо:

{
  name: null
}

nullable()

Разрешает null, но не отсутствие поля.

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

Допустимо:

{
  name: null
}

Недопустимо:

{}

nullish()

Комбинирует optional() и nullable().

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

Допустимо:

{}
{
  name: undefined
}
{
  name: null
}

Различие между пустой строкой и отсутствием значения

Одна из самых распространённых проблем при работе с HTML-формами.

{
  email: ""
}

Технически это строка. Проверка z.string() будет успешной.


Проверка непустой строки

const schema = z.string().min(1)

Либо:

const schema = z.string().nonempty()

Игнорирование пробелов

Пользователь может отправить:

"     "

Для корректной обработки используется trim().

const schema = z.string().trim().min(1)

Теперь строка из пробелов станет пустой после обрезки.


Edge case чисел: NaN

Особенность Jav * aScript:

typeof NaN === "number"

Из-за этого обычная проверка типа недостаточна.


Поведение z.number()

const schema = z.number()

Недопустимо:

NaN

Zod автоматически исключает NaN.


Проверка бесконечности

Infinity
-Infinity

По умолчанию:

z.number()

разрешает бесконечность.

Для запрета:

const schema = z.number().finite()

Edge case: отрицательный ноль

В JavaScript существует:

-0

Проверка:

Object.is(-0, 0) // false

Для большинства приложений это не имеет значения, но в финансовых вычислениях или математических библиотеках проблема может быть критичной.


Проверка отрицательного нуля

const schema = z.number().refine(
  value => !Object.is(value, -0),
  {
    message: "Negative zero is forbidden"
  }
)

Целые числа

const schema = z.number().int()

Недопустимо:

1.5

Безопасные числа JavaScript

JavaScript имеет ограничение:

Number.MAX_SAFE_INTEGER

и

Number.MIN_SAFE_INTEGER

За пределами этих значений возможна потеря точности.


Проверка safe integer

const schema = z.number().safe()

Обработка строковых чисел

Типичная проблема HTTP API:

{
  "age": "25"
}

coerce

const schema = z.coerce.number()

Теперь:

schema.parse("25")

вернёт:

25

Опасности coercion

Number("")
// 0
Number("   ")
// 0
Number(null)
// 0

Это может приводить к скрытым ошибкам.


Безопасная обработка coercion

const schema = z.string()
  .trim()
  .min(1)
  .transform(value => Number(value))
  .refine(value => !Number.isNaN(value))

Edge case boolean

HTTP-формы часто отправляют:

"true"
"false"
"1"
"0"

Обычный boolean schema не обработает их.


Безопасное преобразование boolean

const booleanSchema = z
  .string()
  .transform(value => value.toLowerCase())
  .refine(
    value => ["true", "false"].includes(value)
  )
  .transform(value => value === "true")

Edge case Date

JavaScript Date имеет множество ловушек.


Невалидная дата

new Date("invalid")

Результат:

Invalid Date

Но объект всё равно существует.


Проверка даты

const schema = z.date()

Недопустимо:

Invalid Date

Преобразование строки в Date

const schema = z.coerce.date()

Проблема timezone

new Date("2025-01-01")

Интерпретация зависит от timezone окружения.


Безопасная ISO-проверка

const schema = z.string().datetime()

Ограничение timezone

const schema = z.string().datetime({
  offset: true
})

Теперь строка обязана содержать timezone offset.


Edge case массивов

Пустой массив:

[]

формально корректен.


Запрет пустого массива

const schema = z.array(z.string()).nonempty()

Ограничение размера массива

const schema = z.array(z.string())
  .min(1)
  .max(10)

Удаление дубликатов

const schema = z.array(z.string()).refine(
  values => new Set(values).size === values.length,
  {
    message: "Array contains duplicates"
  }
)

Edge case объектов

По умолчанию Zod удаляет неизвестные поля.


Поведение по умолчанию

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

schema.parse({
  name: "Alex",
  admin: true
})

Результат:

{
  name: "Alex"
}

Поле admin исчезнет.


Строгий режим

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

Теперь лишние поля вызовут ошибку.


Разрешение дополнительных полей

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

Валидация неизвестных ключей

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

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


Edge case union

Union проверяются последовательно.

const schema = z.union([
  z.string(),
  z.number()
])

Неоднозначные union

const schema = z.union([
  z.object({
    type: z.string()
  }),

  z.object({
    type: z.literal("admin"),
    role: z.string()
  })
])

Первый вариант перехватит второй.


Решение: discriminated union

const schema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("user"),
    name: z.string()
  }),

  z.object({
    type: z.literal("admin"),
    permissions: z.array(z.string())
  })
])

Edge case transform

Transform способен скрывать ошибки.


Небезопасный transform

const schema = z.string().transform(value => {
  return JSON.parse(value)
})

JSON.parse() может выбросить исключение.


Безопасный transform

const schema = z.string().transform((value, ctx) => {
  try {
    return JSON.parse(value)
  } catch {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Invalid JSON"
    })

    return z.NEVER
  }
})

Edge case refine

refine() не изменяет тип.

const schema = z.string().refine(
  value => value.length > 5
)

TypeScript всё ещё считает результат строкой.


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

Позволяет создавать несколько ошибок одновременно.

const schema = z.object({
  password: z.string(),
  confirm: z.string()
}).superRefine((data, ctx) => {
  if (data.password !== data.confirm) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["confirm"],
      message: "Passwords do not match"
    })
  }

  if (data.password.length < 8) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["password"],
      message: "Password too short"
    })
  }
})

Асинхронные edge cases

Некоторые проверки требуют обращения к базе данных или API.


Асинхронный refine

const schema = z.string().refine(
  async value => {
    const exists = await checkUser(value)
    return !exists
  },
  {
    message: "User already exists"
  }
)

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

await schema.parseAsync(data)

Обычный parse() не работает с async refine.


Edge case рекурсивных структур

Например:

{
  value: 1,
  children: [
    {
      value: 2,
      children: []
    }
  ]
}

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

const TreeNodeSchema: z.ZodType<any> = z.lazy(() =>
  z.object({
    value: z.number(),
    children: z.array(TreeNodeSchema)
  })
)

Проблема циклических ссылок

const obj: any = {}
obj.self = obj

Такие структуры способны вызывать stack overflow.

Zod не предназначен для обработки циклических объектов.


Edge case enum

enum Role {
  ADMIN = "ADMIN",
  USER = "USER"
}

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

const schema = z.nativeEnum(Role)

Проблема числовых enum TypeScript

enum Status {
  OK,
  ERROR
}

TypeScript создаёт reverse mapping:

{
  0: "OK",
  1: "ERROR",
  OK: 0,
  ERROR: 1
}

Из-за этого возможны неожиданные результаты.


Предпочтение string enum

enum Status {
  OK = "OK",
  ERROR = "ERROR"
}

Edge case bigint

JavaScript Number не способен безопасно хранить большие значения.


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

const schema = z.bigint()

Преобразование bigint

const schema = z.coerce.bigint()

Edge case JSON

JSON не поддерживает:

  • undefined
  • BigInt
  • Date
  • Map
  • Set
  • функции

Проверка JSON-совместимости

const JsonSchema: z.ZodType<any> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    z.null(),
    z.array(JsonSchema),
    z.record(JsonSchema)
  ])
)

Edge case Map

const schema = z.map(
  z.string(),
  z.number()
)

Edge case Set

const schema = z.set(z.string())

Ограничение размера Set

const schema = z.set(z.string())
  .min(1)
  .max(5)

Обработка пустых объектов

z.object({})

принимает любой объект.


Запрет дополнительных полей

z.object({}).strict()

Edge case promise

const schema = z.promise(z.string())

Проверка результата promise

await schema.parseAsync(
  Promise.resolve("hello")
)

Edge case brand types

Branding позволяет создавать псевдономинальные типы.


Пример brand

const UserId = z.string().brand<"UserId">()

type UserId = z.infer<typeof UserId>

Теперь:

type ProductId = string

не совместим с UserId.


Edge case preprocess

preprocess() выполняется до основной валидации.


Пример preprocess

const schema = z.preprocess(
  value => {
    if (typeof value === "string") {
      return value.trim()
    }

    return value
  },

  z.string().min(1)
)

Проблема silent transformation

Скрытые преобразования могут неожиданно менять данные.

Например:

"00123"

после coercion станет:

123

Иногда это разрушает бизнес-логику.


Проверка точного формата

const schema = z.string().regex(/^\d+$/)

Edge case email

Проверка email — одна из самых сложных задач валидации.


Ограничения email()

z.string().email()

не гарантирует существование адреса.

Проверяется только синтаксис.


Unicode email

"user@пример.рф"

может быть валиден по RFC, но не поддерживаться инфраструктурой приложения.


Ограничение ASCII email

const schema = z.string().regex(
  /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,}$/
)

Edge case URL

z.string().url()

проверяет синтаксис URL, но не существование ресурса.


Ограничение protocol

const schema = z.string().url().refine(
  value => {
    const url = new URL(value)

    return ["https:"].includes(url.protocol)
  },
  {
    message: "Only HTTPS allowed"
  }
)

Edge case performance

Очень глубокие структуры могут быть дорогими при валидации.


Ограничение глубины

const MAX_DEPTH = 5

function createSchema(depth = 0): z.ZodType<any> {
  return z.object({
    value: z.string(),

    children:
      depth >= MAX_DEPTH
        ? z.array(z.never())
        : z.array(createSchema(depth + 1))
  })
}

Безопасная обработка ошибок

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

schema.parse(data)

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

const result = schema.safeParse(data)

Структура результата

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

Flatten errors

result.error.flatten()

Удобно для UI-форм.


Глобальный обработчик ошибок

z.setErrorMap((issue, ctx) => {
  return {
    message: `Validation error: ${ctx.defaultError}`
  }
})

Локализация ошибок

z.setErrorMap((issue) => {
  switch (issue.code) {
    case z.ZodIssueCode.invalid_type:
      return {
        message: "Неверный тип данных"
      }

    default:
      return {
        message: "Ошибка валидации"
      }
  }
})

Edge case пересечения схем

const A = z.object({
  id: z.string()
})

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

Проблема intersection

z.intersection(A, B)

создаст невозможную схему.


Проверка совместимости

Перед intersection необходимо гарантировать совместимость типов.


Edge case partial

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

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

const UpdateSchema = UserSchema.partial()

Теперь все поля optional.


Проблема nested partial

const schema = z.object({
  profile: z.object({
    name: z.string()
  })
}).partial()

profile.name останется обязательным.


Deep partial

const DeepPartial = schema.deepPartial()

Edge case default

const schema = z.string().default("anonymous")

Особенность default

Default применяется только к undefined.

schema.parse(undefined)

вернёт:

"anonymous"

Но:

schema.parse(null)

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


Комбинация nullish + default

const schema = z.string()
  .nullish()
  .transform(value => value ?? "anonymous")