Кэширование схем

Причины необходимости кэширования

При активном использовании Zod в реальных приложениях часто возникает ситуация, когда схемы создаются многократно в процессе выполнения программы. Особенно это заметно в следующих случаях:

  • генерация схем внутри функций
  • динамическое построение схем на основе входных параметров
  • использование схем в серверных обработчиках, вызываемых на каждый запрос
  • повторное создание одинаковых структур в разных модулях

Каждое создание схемы в Zod — это выполнение цепочки конструкторов, валидационных описаний и внутренних структур. Хотя сам Zod оптимизирован, повторная генерация идентичных схем приводит к лишним затратам памяти и CPU, а также ухудшает предсказуемость поведения в системах с высокой нагрузкой.

Кэширование схем решает две основные задачи:

  • уменьшение количества создаваемых объектов схем
  • стабилизация ссылочной идентичности схем для повторного использования (memoization-friendly поведение)

Идентичность схем и её значение

Схемы Zod являются объектами. Это означает, что даже при одинаковой структуре две схемы не равны по ссылке:

const a = z.object({ name: z.string() });
const b = z.object({ name: z.string() });

a === b; // false

Это критично в следующих сценариях:

  • использование схем в качестве ключей в Map/WeakMap
  • сравнение схем в тестах
  • подключение middleware, зависящего от конкретного экземпляра схемы
  • интеграция с роутерами и RPC-слоями

Кэширование позволяет гарантировать, что одинаковая логическая схема всегда имеет одну и ту же ссылку.


Базовое кэширование через модульный уровень

Наиболее простой и часто используемый подход — вынесение схем в область модуля:

import { z } fr om "zod";

export const userSchema = z.object({
  id: z.string(),
  email: z.string().email(),
});

Такой подход обеспечивает:

  • единственный экземпляр схемы в процессе выполнения модуля
  • отсутствие повторной инициализации при повторных импортax
  • минимальные накладные расходы

Особенно эффективно это работает в средах с кешированием модулей (Node.js, большинство bundler-ов).


Кэширование динамических схем

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

function createUserSchema(minPasswordLength: number) {
  return z.object({
    password: z.string().min(minPasswordLength),
  });
}

Каждый вызов функции создаёт новую схему, даже если параметр одинаковый.

Решение — использование кэша на основе параметров:

const schemaCache = new Map<number, ReturnType<typeof createUserSchema>>();

function getUserSchema(minPasswordLength: number) {
  if (schemaCache.has(minPasswordLength)) {
    return schemaCache.get(minPasswordLength)!;
  }

  const schema = z.object({
    password: z.string().min(minPasswordLength),
  });

  schemaCache.set(minPasswordLength, schema);
  return schema;
}

Такой подход превращает генерацию схемы в операцию с амортизированной стоимостью O(1) после первого вызова.


Кэширование по составным параметрам

В реальных системах параметры часто многокомпонентные:

  • роли пользователя
  • региональные настройки
  • feature flags
  • типы входных данных

В таких случаях ключ кэша должен быть сериализуемым:

type SchemaKey = {
  role: string;
  strict: boolean;
};

const cache = new Map<string, z.ZodSchema>();

function getSchema(key: SchemaKey) {
  const cacheKey = JSON.stringify(key);

  const cached = cache.get(cacheKey);
  if (cached) return cached;

  let schema = z.object({
    id: z.string(),
  });

  if (key.strict) {
    schema = schema.strict();
  }

  cache.set(cacheKey, schema);
  return schema;
}

Использование JSON-сериализации допустимо при стабильной структуре ключей, однако требует контроля порядка полей.


Использование WeakMap для кэширования

При необходимости связывать схемы с объектами конфигурации, полезен WeakMap:

const cache = new WeakMap<object, z.ZodSchema>();

function getSchema(config: object) {
  const cached = cache.get(config);
  if (cached) return cached;

  const schema = z.object({
    value: z.string(),
  });

  cache.set(config, schema);
  return schema;
}

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

  • автоматическая очистка памяти при удалении ссылок на ключи
  • отсутствие утечек памяти в долгоживущих процессах

Кэширование в фабриках схем

При построении больших систем часто используются фабрики схем:

function createApiSchema(version: string) {
  return z.object({
    version: z.literal(version),
    payload: z.any(),
  });
}

Без кэширования каждая версия создаёт новую структуру.

Оптимизированный вариант:

const apiSchemaCache = new Map<string, z.ZodSchema>();

function getApiSchema(version: string) {
  if (apiSchemaCache.has(version)) {
    return apiSchemaCache.get(version)!;
  }

  const schema = z.object({
    version: z.literal(version),
    payload: z.any(),
  });

  apiSchemaCache.set(version, schema);
  return schema;
}

Влияние на производительность в серверных средах

В средах вроде serverless или edge runtime схемы могут пересоздаваться при каждом cold start. В таких условиях кэширование:

  • уменьшает время инициализации
  • снижает пиковую нагрузку CPU
  • стабилизирует latency первых запросов

Однако при некорректной реализации возможно:

  • накопление памяти при бесконтрольном росте Map
  • хранение избыточных вариантов схем
  • деградация производительности сериализации ключей

Кэширование и z.lazy

При использовании рекурсивных структур через z.lazy важно учитывать, что ленивые схемы уже частично решают проблему повторного создания:

const nodeSchema = z.lazy(() =>
  z.object({
    value: z.string(),
    children: z.array(nodeSchema),
  })
);

Кэширование в таких случаях чаще применяется не к самой схеме, а к фабрике, создающей корневую структуру.


Ошибки при кэшировании схем

На практике встречаются типовые проблемы:

1. Кэширование через объектные ключи без стабилизации

cache.set({ a: 1 }, schema); // всегда новый ключ

2. Утечки памяти через глобальные Map

При накоплении уникальных параметров кэш становится неограниченным.

3. Смешивание схем разных контекстов

Например, объединение схем API v1 и v2 в одном кэше без разделения пространства имён.


Стратегии ограничения роста кэша

Для долгоживущих процессов применяются ограничения:

  • LRU-подход (удаление наименее используемых схем)
  • ограничение по количеству записей
  • сегментация кэша по контекстам (tenant-based caching)

Пример LRU-структуры:

class LRUCache<K, V> {
  private map = new Map<K, V>();
  constructor(private lim it: number) {}

  get(key: K): V | undefined {
    const value = this.map.get(key);
    if (!value) return undefined;

    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }

  set(key: K, value: V) {
    if (this.map.has(key)) {
      this.map.delete(key);
    }

    this.map.set(key, value);

    if (this.map.size > this.limit) {
      const firstKey = this.map.keys().next().value;
      this.map.delete(firstKey);
    }
  }
}

Кэширование и типовая безопасность

TypeScript-типизация Zod схем не зависит от кэширования, поскольку типы выводятся на этапе компиляции:

const schema = z.object({
  id: z.string(),
});

type SchemaType = z.infer<typeof schema>;

Кэширование влияет только на runtime-экземпляры, не затрагивая систему типов. Это позволяет безопасно применять memoization без потери типовой строгости.


Использование фабрик с мемоизацией

Универсальный подход — обёртка фабрик через memoization:

function memoize<TArgs extends any[], TReturn>(
  fn: (...args: TArgs) => TReturn
) {
  const cache = new Map<string, TReturn>();

  return (...args: TArgs): TReturn => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

Применение:

const getSchema = memoize((minLen: number) =>
  z.object({
    password: z.string().min(minLen),
  })
);