Записи и словари

В JavaScript объект нередко используется как словарь: ключи формируются динамически, а значения имеют общий тип. Для описания таких структур в Zod используются z.record() и связанные механизмы работы с ключами объектов.

Подобные схемы особенно полезны при:

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

z.record()

Базовый способ описания словаря — функция z.record().

import { z } from "zod";

const ScoresSchema = z.record(z.number());

Схема означает:

  • ключи — строки;
  • значения — числа.

Проверка:

ScoresSchema.parse({
  alice: 100,
  bob: 95,
});

Ошибка:

ScoresSchema.parse({
  alice: 100,
  bob: "95",
});

Результат:

Expected number, received string

Особенности ключей объекта

В JavaScript ключи обычного объекта автоматически приводятся к строке.

const obj = {
  1: "one",
};

console.log(Object.keys(obj));

Результат:

["1"]

Поэтому z.record() по умолчанию всегда работает со строковыми ключами.


Явное указание типа значения

Наиболее распространённый вариант:

const StringMap = z.record(z.string());

Пример:

StringMap.parse({
  title: "Zod",
  version: "4",
});

Словарь массивов

const TagsSchema = z.record(z.array(z.string()));

Данные:

{
  article1: ["javascript", "zod"],
  article2: ["typescript"],
}

Словарь объектов

z.record() часто комбинируется с z.object().

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

const UsersMapSchema = z.record(UserSchema);

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

UsersMapSchema.parse({
  user1: {
    id: 1,
    name: "Alice",
  },

  user2: {
    id: 2,
    name: "Bob",
  },
});

Типизация через z.infer

type UsersMap = z.infer<typeof UsersMapSchema>;

Тип:

type UsersMap = Record<
  string,
  {
    id: number;
    name: string;
  }
>;

Ограничение значений

Минимальная длина строк

const DictionarySchema = z.record(
  z.string().min(3)
);

Ограничение чисел

const PricesSchema = z.record(
  z.number().positive()
);

Nullable-значения

const CacheSchema = z.record(
  z.string().nullable()
);

Пример:

{
  item1: "value",
  item2: null,
}

Ограничение ключей

Проверка ключей через enum

z.record() может принимать схему ключа первым аргументом.

const SettingsSchema = z.record(
  z.enum(["theme", "language"]),
  z.string()
);

Разрешены только:

  • theme
  • language

Проверка:

SettingsSchema.parse({
  theme: "dark",
  language: "ru",
});

Ошибка:

SettingsSchema.parse({
  theme: "dark",
  locale: "ru",
});

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

const FixedSchema = z.record(
  z.literal("token"),
  z.string()
);

Допустимо:

{
  token: "abc123"
}

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

{
  session: "abc123"
}

Union ключей

const Schema = z.record(
  z.union([
    z.literal("en"),
    z.literal("ru"),
    z.literal("de"),
  ]),
  z.string()
);

Record и TypeScript Record

TypeScript

type UserMap = Record<string, User>;

Zod

const UserMapSchema = z.record(UserSchema);

Комбинация:

type UserMap = z.infer<typeof UserMapSchema>;

Record против object

z.object()

Используется при фиксированной структуре.

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

z.record()

Используется при динамических ключах.

const ScoresSchema = z.record(z.number());

Комбинация object и record

Часто объект содержит как фиксированные поля, так и динамические.

const ConfigSchema = z.object({
  appName: z.string(),
  env: z.string(),
  variables: z.record(z.string()),
});

Пример:

{
  appName: "MyApp",
  env: "production",

  variables: {
    API_URL: "https://api.test.com",
    TOKEN: "secret",
  },
}

Вложенные записи

const MatrixSchema = z.record(
  z.record(z.number())
);

Данные:

{
  row1: {
    col1: 1,
    col2: 2,
  },

  row2: {
    col1: 3,
    col2: 4,
  },
}

Record с union значений

const MixedSchema = z.record(
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
  ])
);

Использование .optional()

const OptionalValuesSchema = z.record(
  z.string().optional()
);

Допустимо:

{
  a: "hello",
  b: undefined,
}

Использование .default()

const SettingsSchema = z.record(
  z.string()
).default({});

Пример:

SettingsSchema.parse(undefined);

Результат:

{}

Record и .catchall()

Иногда требуется фиксированный объект с дополнительными динамическими полями.

Для этого используется .catchall().

const ApiResponseSchema = z.object({
  success: z.boolean(),
})
.catchall(z.string());

Допустимо:

{
  success: true,
  requestId: "abc",
  traceId: "xyz",
}

Все дополнительные поля должны быть строками.


Разница между record и catchall

record

Весь объект состоит из динамических ключей.

z.record(z.string())

catchall

Есть фиксированные поля плюс дополнительные.

z.object({
  id: z.number(),
}).catchall(z.string())

Строгие объекты и словари

.strict()

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

Дополнительные поля запрещены.


.passthrough()

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

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


.catchall()

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

Дополнительные поля разрешены, но должны быть числами.


Валидация ключей через regex

Прямой поддержки regex-ключей в record нет, однако это можно реализовать через .superRefine().

const EnvSchema = z.record(z.string())
  .superRefine((obj, ctx) => {
    for (const key of Object.keys(obj)) {
      if (!/^APP_/.test(key)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `Invalid key: ${key}`,
        });
      }
    }
  });

Допустимо:

{
  APP_URL: "http://localhost",
  APP_TOKEN: "secret",
}

Ошибка:

{
  URL: "http://localhost"
}

Трансформация record

const NumberRecordSchema = z.record(z.string())
  .transform((obj) => {
    return Object.fromEntries(
      Object.entries(obj).map(([k, v]) => [
        k,
        Number(v),
      ])
    );
  });

Пример:

NumberRecordSchema.parse({
  a: "1",
  b: "2",
});

Результат:

{
  a: 1,
  b: 2,
}

Асинхронная проверка

const UserIdsSchema = z.record(
  z.string()
).superRefine(async (obj, ctx) => {
  for (const [key, value] of Object.entries(obj)) {
    const exists = await checkUser(value);

    if (!exists) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: `Unknown user: ${value}`,
        path: [key],
      });
    }
  }
});

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

await UserIdsSchema.parseAsync(data);

Partial Record

Иногда нужен частично заполненный словарь.

const LocaleSchema = z.record(
  z.enum(["ru", "en", "de"]),
  z.string()
);

Можно передавать:

{
  ru: "Привет"
}

Все ключи не обязательны.


Record с readonly

const ReadonlySchema = z.record(
  z.string()
).readonly();

Тип:

Readonly<Record<string, string>>

Ошибки валидации

Пример:

const Schema = z.record(z.number());

Schema.safeParse({
  a: 1,
  b: "2",
});

Результат:

{
  success: false,
  error: ZodError
}

Детали:

[
  {
    code: "invalid_type",
    expected: "number",
    received: "string",
    path: ["b"],
    message: "Expected number, received string"
  }
]

Практические сценарии

Переводы интерфейса

const TranslationSchema = z.record(
  z.string()
);

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

const EnvSchema = z.record(
  z.string()
);

Кэш значений

const CacheSchema = z.record(
  z.any()
);

Коллекция сущностей

const ProductSchema = z.object({
  id: z.number(),
  title: z.string(),
});

const ProductsSchema = z.record(
  ProductSchema
);

Record и JSON API

Многие API возвращают данные именно в формате словаря.

{
  "users": {
    "1": {
      "name": "Alice"
    },
    "2": {
      "name": "Bob"
    }
  }
}

Схема:

const ApiSchema = z.object({
  users: z.record(
    z.object({
      name: z.string(),
    })
  ),
});

Производительность

z.record() валидирует:

  1. каждый ключ;
  2. каждое значение;
  3. вложенные схемы.

При работе с большими объектами это может быть затратной операцией.

Особенно дорого обходятся:

  • глубокая вложенность;
  • superRefine;
  • асинхронные проверки;
  • сложные union-схемы.

Типичные ошибки

Использование object вместо record

Неправильно:

z.object({})

для динамических ключей.

Правильно:

z.record(z.string())

Слишком широкий z.any()

z.record(z.any())

убирает преимущества типизации и валидации.


Игнорирование safeParse

schema.parse(data)

генерирует исключение.

Для безопасной обработки лучше:

schema.safeParse(data)

Рекомендации

Использовать record для словарей

Если ключи заранее неизвестны:

z.record(...)

Ограничивать типы ключей

z.record(
  z.enum(["ru", "en"]),
  z.string()
)

Избегать any

Предпочтительно:

z.unknown()

или конкретные схемы.


Комбинировать с object

Фиксированные поля:

z.object(...)

Динамические:

z.record(...)

Краткая сводка

Конструкция Назначение
z.record(valueSchema) словарь значений
z.record(keySchema, valueSchema) словарь с ограничением ключей
.catchall() дополнительные поля объекта
.strict() запрет лишних полей
.passthrough() пропуск дополнительных полей
.readonly() readonly-словарь
.transform() преобразование данных
.superRefine() сложная пользовательская проверка