Множества и Map

В JavaScript структуры Set и Map используются для хранения уникальных значений и коллекций пар ключ–значение. Библиотека Zod предоставляет специальные схемы для строгой валидации этих структур:

  • z.set()
  • z.map()

Обе схемы поддерживают:

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

Валидация Set

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

Для описания множества используется z.set().

import { z } from "zod";

const tagsSchema = z.set(z.string());

tagsSchema.parse(new Set(["js", "ts"]));

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

  • объект должен быть экземпляром Set;
  • каждый элемент множества обязан быть строкой.

Ошибка при неверном типе элемента

tagsSchema.parse(new Set(["js", 123]));

Ошибка:

Expected string, received number

Zod проверяет каждый элемент множества отдельно.


Типизация Set

Получение TypeScript-типа

const rolesSchema = z.set(z.string());

type Roles = z.infer<typeof rolesSchema>;

Результат:

type Roles = Set<string>;

Проверка размера множества

Минимальное количество элементов

const schema = z.set(z.string()).min(2);

Допустимые значения:

new Set(["admin", "user"])

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

new Set(["admin"])

Максимальное количество элементов

const schema = z.set(z.string()).max(5);

Точное количество элементов

const schema = z.set(z.string()).size(3);

Только множества из трёх элементов будут валидны.


Настройка сообщений об ошибках

Кастомное сообщение

const schema = z
  .set(z.string())
  .min(2, "Минимум два элемента");

Вложенные схемы в Set

Множество объектов

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

const usersSchema = z.set(userSchema);

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

usersSchema.parse(
  new Set([
    { id: 1, name: "Alex" },
    { id: 2, name: "John" },
  ])
);

Использование enum внутри Set

Ограниченный набор значений

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

const rolesSchema = z.set(roleSchema);

Допустимо:

new Set(["admin", "user"])

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

new Set(["superadmin"])

Проверка через refine

Пользовательская логика

const schema = z
  .set(z.string())
  .refine(
    (set) => set.has("admin"),
    {
      message: "Множество должно содержать admin",
    }
  );

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

Конвертация в массив

const schema = z
  .set(z.string())
  .transform((set) => [...set]);

Результат:

["a", "b", "c"]

Сортировка после преобразования

const schema = z
  .set(z.string())
  .transform((set) => [...set].sort());

Предобработка входных данных

Автоматическое создание Set

const schema = z.preprocess(
  (value) => {
    if (Array.isArray(value)) {
      return new Set(value);
    }

    return value;
  },
  z.set(z.string())
);

Теперь массив автоматически преобразуется в Set.

schema.parse(["a", "b", "c"]);

safeParse для Set

Без выброса исключения

const result = z
  .set(z.number())
  .safeParse(new Set([1, 2, "3"]));

Результат:

{
  success: false,
  error: ZodError
}

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

parseAsync

const schema = z
  .set(z.string())
  .refine(async (set) => {
    return set.size > 0;
  });

await schema.parseAsync(new Set(["a"]));

Валидация Map

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

Для Map используется z.map().

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

Первый аргумент:

  • схема ключа.

Второй аргумент:

  • схема значения.

Проверка структуры

schema.parse(
  new Map([
    ["apples", 10],
    ["oranges", 20],
  ])
);

Ошибка ключа

schema.parse(
  new Map([
    [123, 10],
  ])
);

Ошибка:

Expected string, received number

Ошибка значения

schema.parse(
  new Map([
    ["apples", "10"],
  ])
);

Типизация Map

Получение типа

const schema = z.map(
  z.string(),
  z.boolean()
);

type Flags = z.infer<typeof schema>;

Результат:

type Flags = Map<string, boolean>;

Использование сложных схем в Map

Объекты в значениях

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

const schema = z.map(
  z.string(),
  productSchema
);

Enum в ключах

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

const schema = z.map(
  keySchema,
  z.boolean()
);

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

Минимальный размер

const schema = z
  .map(z.string(), z.number())
  .min(1);

Максимальный размер

const schema = z
  .map(z.string(), z.number())
  .max(10);

Фиксированный размер

const schema = z
  .map(z.string(), z.number())
  .size(2);

Проверка через refine

Проверка значений

const schema = z
  .map(z.string(), z.number())
  .refine(
    (map) => {
      for (const value of map.values()) {
        if (value < 0) {
          return false;
        }
      }

      return true;
    },
    {
      message: "Отрицательные значения запрещены",
    }
  );

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

Конвертация в объект

const schema = z
  .map(z.string(), z.number())
  .transform((map) =>
    Object.fromEntries(map)
  );

Результат:

{
  apples: 10,
  oranges: 20
}

Преобразование объекта в Map

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

const schema = z.preprocess(
  (value) => {
    if (
      typeof value === "object" &&
      value !== null &&
      !Array.isArray(value)
    ) {
      return new Map(
        Object.entries(value)
      );
    }

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

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

schema.parse({
  apples: 10,
  oranges: 20,
});

Вложенные структуры

Map со значениями Set

const schema = z.map(
  z.string(),
  z.set(z.string())
);

Пример:

new Map([
  ["admins", new Set(["alex"])],
  ["users", new Set(["john"])],
]);

Set объектов с Map

const schema = z.set(
  z.object({
    name: z.string(),
    permissions: z.map(
      z.string(),
      z.boolean()
    ),
  })
);

Работа с nullable и optional

Nullable Set

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

Допустимые значения:

null
new Set(["a"])

Optional Map

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

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

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

const schema = z
  .set(z.string())
  .default(new Set());

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

const schema = z
  .map(z.string(), z.number())
  .default(new Map());

Комбинирование с union

Несколько допустимых структур

const schema = z.union([
  z.set(z.string()),
  z.array(z.string()),
]);

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

Хотя z.set() и z.map() предпочтительнее, иногда используется instanceof.

Проверка экземпляра Set

const schema = z.instanceof(Set);

Проверка экземпляра Map

const schema = z.instanceof(Map);

Однако такой подход:

  • не валидирует содержимое;
  • проверяет только сам класс объекта.

Отличие Set от массива в Zod

z.array()

z.array(z.string())

Особенности:

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

z.set()

z.set(z.string())

Особенности:

  • элементы уникальны;
  • отсутствуют индексы;
  • проверяется экземпляр Set.

Отличие Map от объекта

z.object()

z.object({
  name: z.string(),
})

Статическая структура.


z.map()

z.map(z.string(), z.number())

Динамический набор ключей.


Практический пример: система ролей

Хранение разрешений

const permissionsSchema = z.map(
  z.string(),
  z.set(
    z.enum([
      "read",
      "write",
      "delete",
    ])
  )
);

Пример данных:

const permissions = new Map([
  [
    "admin",
    new Set([
      "read",
      "write",
      "delete",
    ]),
  ],
  [
    "user",
    new Set(["read"]),
  ],
]);

Валидация:

permissionsSchema.parse(
  permissions
);

Практический пример: кэш объектов

const cacheSchema = z.map(
  z.string(),
  z.object({
    createdAt: z.date(),
    value: z.unknown(),
  })
);

Практический пример: уникальные теги

const tagsSchema = z
  .set(z.string())
  .transform((set) =>
    [...set].map((tag) =>
      tag.toLowerCase()
    )
  );

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

Формат ошибок

try {
  z.set(z.number()).parse(
    new Set([1, "2"])
  );
} catch (err) {
  console.log(err.errors);
}

Результат:

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

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

Особенности проверки Set

При валидации:

  1. Zod итерирует каждый элемент;
  2. применяет вложенную схему;
  3. собирает ошибки.

На больших множествах сложные refine могут существенно влиять на производительность.


Особенности проверки Map

Для Map отдельно валидируются:

  • ключи;
  • значения.

Чем сложнее вложенные схемы, тем выше стоимость проверки.


Рекомендации по использованию

Когда использовать Set

Подходит для:

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

Когда использовать Map

Подходит для:

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

Совместимость с JSON

Ограничение Set и Map

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

  • Set;
  • Map.

Перед сериализацией необходимо преобразование.


Сериализация Set

const serialized = JSON.stringify([
  ...mySet
]);

Сериализация Map

const serialized = JSON.stringify(
  Object.fromEntries(myMap)
);

Десериализация

Восстановление Set

const set = new Set(
  JSON.parse(json)
);

Восстановление Map

const map = new Map(
  Object.entries(
    JSON.parse(json)
  )
);