Ревью TypeScript под нагрузкой: что ломается первым
Код, который проходит ревью по типам, падает в проде на объёме: Promise.all кладёт память, any прячет невалидные данные, JSON.parse без валидации роняет обработчик. Разбираем формулировки замечаний, которые ловят это до релиза.

Сервис на Node 20 и TypeScript 5.4 держал сотню запросов в секунду и начал отдавать ECONNRESET и heap out of memory на пиках. Код прошёл ревью: типы строгие, strict: true, ESLint без ошибок. Проблема в том, что компилятор проверяет форму данных, а не их объём и не то, что придёт по сети в рантайме. Ниже — замечания, которые стоит писать на ревью TypeScript-кода, если он поедет под нагрузку, и как именно каждая из этих конструкций ломается первой.
Речь не про стиль и не про то, что «any — это плохо». Речь про конкретные строки, которые компилятор пропускает, а прод — нет.
Promise.all на неограниченном массиве
Самое частое замечание, которое пропускают. Разработчик получает массив ID и делает так:
// Node 20, TypeScript 5.4
// Проблема: параллельность равна длине массива
async function loadUsers(ids: number[]): Promise<User[]> {
return Promise.all(ids.map((id) => fetchUser(id)));
}
Типы идеальны: на входе number[], на выходе Promise<User[]>. Но если ids придёт с 20 000 элементов, вы одновременно откроете 20 000 запросов к базе или внешнему API. База отдаст too many connections, внешний сервис — 429, а память вырастет на все 20 000 промисов и их результаты сразу.
Формулировка замечания в ревью, которая работает: «Promise.all запускает все задачи разом. Здесь массив приходит из запроса пользователя и не ограничен сверху. Нужен пул с фиксированной параллельностью — например, p-limit или ручной чанкинг. Какой размер пула держит база по числу соединений?»
Рабочий вариант с ограничением через p-limit (версия 5.x, ESM):
import pLimit from 'p-limit';
// не больше 10 одновременных запросов независимо от длины массива
const limit = pLimit(10);
async function loadUsers(ids: number[]): Promise<User[]> {
return Promise.all(ids.map((id) => limit(() => fetchUser(id))));
}
Обратите внимание: сигнатура функции не изменилась, тип тот же. Ревью по типам это различие не поймает никогда — ловится только чтением логики.
any и as на границе с сетью
Второе, что ломается под нагрузкой. Данные из сети, очереди или базы приходят как unknown, а разработчик приводит их к типу:
const body = (await request.json()) as OrderPayload;
// дальше код верит, что body.items — массив
const total = body.items.reduce((sum, i) => sum + i.price, 0);
Под низкой нагрузкой все клиенты шлют корректный JSON, и код работает. Под нагрузкой появляется кривой запрос: items приходит как null или строка. reduce бросает Cannot read properties of null, промис реджектится, а если реджект не пойман — процесс падает с unhandledRejection. Один битый запрос кладёт обработку следующих.
Приведение черезas— это не проверка, а обещание компилятору молчать. На границе процесса, куда приходят чужие данные, каждоеas— это потенциальный500в проде.
Замечание: «as OrderPayload отключает проверку, но данные пришли извне и им нельзя верить. Нужна runtime-валидация — zod, valibot или ручная проверка полей. Что произойдёт, если items придёт null?»
С zod (версия 3.x) граница закрывается так:
import { z } from 'zod';
const OrderSchema = z.object({
items: z.array(z.object({ price: z.number() })),
});
const parsed = OrderSchema.safeParse(await request.json());
if (!parsed.success) {
return reply.status(400).send({ error: 'invalid payload' });
}
// parsed.data типизирован и проверен в рантайме
const total = parsed.data.items.reduce((sum, i) => sum + i.price, 0);
Загрузка всей выборки в память
Третье. Функция возвращает массив, тип честный — Promise<Row[]>, но за ним стоит запрос без LIMIT:
async function exportAll(): Promise<Row[]> {
return db.query('SELECT * FROM events'); // таблица растёт
}
На тестовой базе тысяча строк, на проде — десять миллионов. Node пытается собрать весь результат в один массив в куче, упирается в лимит --max-old-space-size и получает heap out of memory. Тип Row[] ничего не сказал о размере.
Замечание: «Возвращается весь результат в массиве. На проде таблица растёт без ограничений. Здесь нужен курсор или стриминг, а не материализация всей выборки. Сколько строк максимум ожидается?»
Тип есть, а проверки на null нет
TypeScript со strictNullChecks ловит явные null, но не то, что база вернёт пустой массив, а внешний ключ окажется битым:
const [user] = await getUsers(id); // User | undefined на самом деле
return user.email; // тип user выведен как User, но массив мог быть пустым
Если getUsers типизирован как Promise<User[]>, деструктуризация даёт User, хотя массив может быть пуст и user будет undefined. Компилятор доверяет сигнатуре. Под нагрузкой всплывают гонки: запись удалили между двумя запросами, приходит Cannot read properties of undefined.
Замечание: «Деструктуризация первого элемента не гарантирует, что он есть. Включите noUncheckedIndexedAccess в tsconfig — тогда индексный доступ даст User | undefined и компилятор заставит проверить.»
Что писать на ревью: сводка
| Конструкция | Что проходит ревью | Что ломается под нагрузкой | Что предложить |
|---|---|---|---|
Promise.all(arr.map(...)) | типы верные | параллельность = длине массива, исчерпание соединений | пул через p-limit или чанкинг |
as SomeType на входных данных | компилятор молчит | битый запрос роняет обработчик | runtime-валидация (zod/valibot) |
SELECT * в массив | Row[] выглядит честно | heap out of memory на большой таблице | курсор, стриминг, LIMIT |
деструктуризация [x] | тип X | undefined на пустой выборке | noUncheckedIndexedAccess |
Граничные случаи, где даже правильное замечание не спасёт
Не всё ловится ревью, и это стоит понимать, чтобы не создавать ложного чувства безопасности:
- Утечки через замыкания. Обработчик в
setIntervalили в кеше держит ссылку на большой объект. Тип корректен, память течёт медленно и проявляется через часы. Ловится профилировщиком кучи, а не чтением диффа. - Блокировка event loop. Синхронный
JSON.parseна строке в десятки мегабайт или тяжёлый.sort()в цикле. Типы идеальны, но обработка одного запроса замораживает все остальные. Мерить нужно задержку event loop, а не типы. - Валидация против дедлайна запроса. Схема
zodна огромном payload сама по себе может занять сотни миллисекунд. Валидация нужна, но на границе стоит ограничивать размер тела до парсинга.
Эти вещи не заменяют ревью — они показывают его границу. Ревью TypeScript закрывает то, что видно в диффе. Остальное закрывают нагрузочный тест и наблюдаемость.
Что делать дальше
Три шага, которые снимают большую часть описанного до релиза:
- Включите в
tsconfig.jsonфлагиnoUncheckedIndexedAccessиexactOptionalPropertyTypes— они переводят часть рантайм-ошибок в ошибки компиляции. Ставьте на новом коде, на старом чините постепенно. - Добавьте в чек-лист ревью один вопрос к каждому
Promise.allи каждому запросу к базе: «чему равен максимум длины массива / числа строк, и что будет при этом максимуме». - Поставьте нагрузочный тест на эндпоинты, где массив или выборка приходят из пользовательского ввода, и снимайте под ним задержку event loop и размер кучи. Именно там всплывает то, что тип скрыл.
Точные имена флагов компилятора и их поведение между минорными версиями TypeScript меняются — сверяйте с разделом tsconfig в официальной документации для вашей версии.
Переводчик-локализатор: Адаптация текстов под культурные коды RU—EN—CN.
Спросить за 15 ₽Источники: TypeScript — tsconfig compiler options, Zod — official documentation
Частые вопросы
А если у меня старая версия TypeScript и флага noUncheckedIndexedAccess нет?
Замечание написал, а автор говорит «на проде такого объёма не будет». Что отвечать?
Не сработало: поставил p-limit, а память всё равно растёт. Почему?
Zod на каждый запрос — это не медленно?
Компилятор же со strict всё ловит. Зачем ещё runtime-валидация?
Как отличить утечку памяти от нормального роста под нагрузкой?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.