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)
Теперь строка из пробелов станет пустой после обрезки.
NaNОсобенность Jav * aScript:
typeof NaN === "number"
Из-за этого обычная проверка типа недостаточна.
z.number()const schema = z.number()
Недопустимо:
NaN
Zod автоматически исключает NaN.
Infinity
-Infinity
По умолчанию:
z.number()
разрешает бесконечность.
Для запрета:
const schema = z.number().finite()
В 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 имеет ограничение:
Number.MAX_SAFE_INTEGER
и
Number.MIN_SAFE_INTEGER
За пределами этих значений возможна потеря точности.
const schema = z.number().safe()
Типичная проблема HTTP API:
{
"age": "25"
}
coerceconst schema = z.coerce.number()
Теперь:
schema.parse("25")
вернёт:
25
Number("")
// 0
Number(" ")
// 0
Number(null)
// 0
Это может приводить к скрытым ошибкам.
const schema = z.string()
.trim()
.min(1)
.transform(value => Number(value))
.refine(value => !Number.isNaN(value))
HTTP-формы часто отправляют:
"true"
"false"
"1"
"0"
Обычный boolean schema не обработает их.
const booleanSchema = z
.string()
.transform(value => value.toLowerCase())
.refine(
value => ["true", "false"].includes(value)
)
.transform(value => value === "true")
JavaScript Date имеет множество ловушек.
new Date("invalid")
Результат:
Invalid Date
Но объект всё равно существует.
const schema = z.date()
Недопустимо:
Invalid Date
const schema = z.coerce.date()
new Date("2025-01-01")
Интерпретация зависит от timezone окружения.
const schema = z.string().datetime()
const schema = z.string().datetime({
offset: true
})
Теперь строка обязана содержать timezone offset.
Пустой массив:
[]
формально корректен.
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"
}
)
По умолчанию 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())
Теперь все дополнительные поля обязаны быть строками.
Union проверяются последовательно.
const schema = z.union([
z.string(),
z.number()
])
const schema = z.union([
z.object({
type: z.string()
}),
z.object({
type: z.literal("admin"),
role: z.string()
})
])
Первый вариант перехватит второй.
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())
})
])
Transform способен скрывать ошибки.
const schema = z.string().transform(value => {
return JSON.parse(value)
})
JSON.parse() может выбросить исключение.
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
}
})
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"
})
}
})
Некоторые проверки требуют обращения к базе данных или API.
const schema = z.string().refine(
async value => {
const exists = await checkUser(value)
return !exists
},
{
message: "User already exists"
}
)
parseAsyncawait schema.parseAsync(data)
Обычный parse() не работает с async refine.
Например:
{
value: 1,
children: [
{
value: 2,
children: []
}
]
}
z.lazyconst TreeNodeSchema: z.ZodType<any> = z.lazy(() =>
z.object({
value: z.number(),
children: z.array(TreeNodeSchema)
})
)
const obj: any = {}
obj.self = obj
Такие структуры способны вызывать stack overflow.
Zod не предназначен для обработки циклических объектов.
enum Role {
ADMIN = "ADMIN",
USER = "USER"
}
const schema = z.nativeEnum(Role)
enum Status {
OK,
ERROR
}
TypeScript создаёт reverse mapping:
{
0: "OK",
1: "ERROR",
OK: 0,
ERROR: 1
}
Из-за этого возможны неожиданные результаты.
enum Status {
OK = "OK",
ERROR = "ERROR"
}
JavaScript Number не способен безопасно хранить большие значения.
const schema = z.bigint()
const schema = z.coerce.bigint()
JSON не поддерживает:
undefinedBigIntDateMapSetconst JsonSchema: z.ZodType<any> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(JsonSchema),
z.record(JsonSchema)
])
)
const schema = z.map(
z.string(),
z.number()
)
const schema = z.set(z.string())
const schema = z.set(z.string())
.min(1)
.max(5)
z.object({})
принимает любой объект.
z.object({}).strict()
const schema = z.promise(z.string())
await schema.parseAsync(
Promise.resolve("hello")
)
Branding позволяет создавать псевдономинальные типы.
const UserId = z.string().brand<"UserId">()
type UserId = z.infer<typeof UserId>
Теперь:
type ProductId = string
не совместим с UserId.
preprocess() выполняется до основной валидации.
const schema = z.preprocess(
value => {
if (typeof value === "string") {
return value.trim()
}
return value
},
z.string().min(1)
)
Скрытые преобразования могут неожиданно менять данные.
Например:
"00123"
после coercion станет:
123
Иногда это разрушает бизнес-логику.
const schema = z.string().regex(/^\d+$/)
Проверка email — одна из самых сложных задач валидации.
email()z.string().email()
не гарантирует существование адреса.
Проверяется только синтаксис.
"user@пример.рф"
может быть валиден по RFC, но не поддерживаться инфраструктурой приложения.
const schema = z.string().regex(
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,}$/
)
z.string().url()
проверяет синтаксис URL, но не существование ресурса.
const schema = z.string().url().refine(
value => {
const url = new URL(value)
return ["https:"].includes(url.protocol)
},
{
message: "Only HTTPS allowed"
}
)
Очень глубокие структуры могут быть дорогими при валидации.
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)
safeParseconst result = schema.safeParse(data)
if (!result.success) {
console.log(result.error.format())
}
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: "Ошибка валидации"
}
}
})
const A = z.object({
id: z.string()
})
const B = z.object({
id: z.number()
})
z.intersection(A, B)
создаст невозможную схему.
Перед intersection необходимо гарантировать совместимость типов.
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string()
})
const UpdateSchema = UserSchema.partial()
Теперь все поля optional.
const schema = z.object({
profile: z.object({
name: z.string()
})
}).partial()
profile.name останется обязательным.
const DeepPartial = schema.deepPartial()
const schema = z.string().default("anonymous")
Default применяется только к undefined.
schema.parse(undefined)
вернёт:
"anonymous"
Но:
schema.parse(null)
вызовет ошибку.
const schema = z.string()
.nullish()
.transform(value => value ?? "anonymous")