Асинхронность, Движок JS и Продвинутый TypeScript

Глубокое погружение в механизмы исполнения JavaScript и продвинутую систему типов. Курс связывает теорию Event Loop с практикой написания отказоустойчивого кода и использованием мощных инструментов TypeScript для типизации сложных интерфейсов.

Движок V8 и Main Thread: как JavaScript сосуществует с рендерингом

Движок V8 и Main Thread: как JavaScript сосуществует с рендерингом

Современные движки JavaScript выполняют миллионы операций в секунду. Однако, если вы напишете в консоли браузера простой бесконечный цикл while(true) {}, страница намертво зависнет: вы не сможете ни нажать на кнопку, ни проскроллить текст, а все CSS-анимации остановятся. Этот парадокс — невероятная скорость вычислений и абсолютная хрупкость интерфейса — кроется в фундаментальном архитектурном решении: код и отрисовка делят одно и то же пространство.

Чтобы понять природу асинхронности, к которой мы придём в следующих главах, необходимо сначала заглянуть под капот браузера и разобраться, как именно выполняется наш код и почему ему так тесно.

Внутри чёрного ящика: JIT-компиляция в V8

JavaScript изначально задумывался как интерпретируемый язык: браузер должен был читать код строка за строкой и сразу его выполнять. Это давало гибкость, но серьёзно било по производительности. Сегодня движок V8 (используемый в Chrome и Node.js) работает иначе — он использует JIT-компиляцию (Just-In-Time).

Процесс превращения вашего исходного кода в машинные инструкции проходит через строгий конвейер:

  1. Парсинг и AST. Сначала исходный код разбивается на токены и превращается в абстрактное синтаксическое дерево (AST — Abstract Syntax Tree). Это структурное представление логики программы.
  2. Ignition (Интерпретатор). V8 не пытается сразу скомпилировать весь код в машинный. Вместо этого интерпретатор Ignition быстро пробегает по AST и генерирует промежуточный байт-код (bytecode). Байт-код не привязан к конкретному процессору, он выполняется быстро, но не идеально. На этом этапе код уже начинает работать.
  3. Профилирование. Пока Ignition выполняет байт-код, он собирает статистику: какие функции вызываются чаще всего (hot functions), какие типы данных в них передаются.
  4. TurboFan (Оптимизирующий компилятор). Если функция вызывается много раз с одними и теми же типами аргументов, V8 передаёт её в TurboFan. Тот берёт байт-код и компилирует его в высокооптимизированный машинный код под конкретную архитектуру процессора.

Если в оптимизированную функцию вдруг прилетит аргумент другого типа (например, строка вместо числа), TurboFan сделает деоптимизацию — выбросит машинный код и вернёт выполнение обратно интерпретатору Ignition. Именно поэтому в TypeScript строгая типизация не только спасает от багов, но и косвенно помогает движку V8 держать код в оптимизированном состоянии, так как типы данных не меняются на лету.

Анатомия выполнения: Heap и Call Stack

Когда V8 готов выполнять инструкции, ему нужно где-то хранить данные и отслеживать, в каком месте программы он находится. Для этого используются две основные области памяти.

Memory Heap (Куча) — это неструктурированная область памяти, где хранятся объекты, массивы и функции. Когда вы пишете const user = { name: 'Alex' }, движок выделяет место под этот объект именно в куче.

Call Stack (Стек вызовов) — это структура данных, работающая по принципу LIFO (Last In, First Out — последним пришёл, первым ушёл). Стек отвечает за отслеживание контекста выполнения.

Каждый раз, когда вызывается функция, движок создаёт для неё Stack Frame (кадр стека) — блок памяти, содержащий локальные переменные функции и информацию о том, куда нужно вернуться после её завершения. Этот кадр кладётся на вершину стека. Когда функция завершает работу (доходит до return или конца тела), её кадр снимается со стека, и выполнение продолжается с предыдущего кадра.

Поскольку Call Stack в JavaScript ровно один, язык является однопоточным (single-threaded). В любой момент времени может выполняться только одна функция — та, что находится на вершине стека.

Main Thread: битва за ресурсы

Здесь мы подходим к главной проблеме браузерной среды. Вспомните процесс рендеринга: браузер строит DOM и CSSOM, объединяет их в Render Tree, вычисляет геометрию (Layout) и рисует пиксели (Paint).

Вся эта работа по отрисовке интерфейса происходит в Главном потоке (Main Thread). И именно в этом же самом потоке живёт Call Stack движка V8.

JavaScript и рендеринг — это два соседа, которые делят одну ванную комнату. Пока JavaScript выполняет вычисления в стеке, браузер физически не может обновить экран. Процессы Layout и Paint вынуждены ждать, пока стек вызовов полностью не опустеет.

Бюджет кадра и Long Tasks

Для того чтобы анимации на странице выглядели плавными, браузер должен обновлять экран с частотой 60 кадров в секунду (60 FPS). Математика здесь проста: 1000/6016.61000 / 60 \approx 16.6 миллисекунд.

У браузера есть всего ~16.6 мс на то, чтобы выполнить один цикл:

  1. Выполнить накопившийся JavaScript-код.
  2. Пересчитать стили и макет (Layout).
  3. Отрисовать изменения (Paint).

Если выполнение функции в Call Stack занимает, например, 50 мс, браузер пропускает несколько кадров подряд. Пользователь видит это как «фриз» или подтормаживание (jank). Задачи в JavaScript, выполнение которых превышает 50 мс, официально называются Long Tasks (длинные задачи). Современные инструменты разработчика (Chrome DevTools) подсвечивают такие задачи красным цветом, сигнализируя о проблемах с производительностью.

Итог и переход к асинхронности

Движок V8 делает всё возможное, чтобы исполнять код молниеносно с помощью JIT-компиляции. Однако природа однопоточности неумолима: любая тяжёлая синхронная операция, будь то сложный математический расчёт или долгий цикл, берёт Главный поток в заложники.

Мы не можем просто остановить выполнение функции на середине, отдать поток браузеру для отрисовки кадра, а затем вернуться обратно в ту же точку стека (по крайней мере, обычными функциями). Нам нужен механизм, который позволит сказать браузеру: «Запусти эту тяжёлую задачу или ожидание ответа от сервера где-нибудь в фоне, а когда закончишь — положи результат в очередь, я заберу его, когда Call Stack освободится».

Именно эту проблему решает архитектурный паттерн, работающий поверх движка V8, — Event Loop (Цикл событий), к детальному разбору которого мы переходим в следующей главе.

Микрозадачи и макрозадачи: анатомия Event Loop

Микрозадачи и макрозадачи: анатомия Event Loop

Мы знаем, что JavaScript выполняется в единственном главном потоке (Main Thread), и если функция задерживается в Call Stack дольше 50 мс, интерфейс браузера замирает. Возникает парадокс: как тогда мы можем отправить сетевой запрос, который длится 2 секунды, или установить таймер на 10 секунд, не замораживая всю страницу на это время?

Секрет в том, что сам движок V8 не умеет работать с сетью, таймерами или событиями кликов. Он умеет только компилировать и выполнять синхронный код. Вся магия асинхронности происходит за пределами движка — в среде выполнения браузера.

За пределами движка: Web APIs

Когда вы вызываете setTimeout или fetch, V8 не ставит поток на паузу. Он передает эту задачу браузеру, а именно — компонентам, которые называются Web APIs.

Web APIs написаны на низкоуровневых языках (C/C++) и выполняются в фоновых потоках браузера, не мешая нашему Main Thread. Как только вы вызываете fetch, V8 мгновенно завершает выполнение этой строки кода и идет дальше. Всю работу по открытию TCP-соединения, отправке HTTP-запроса и ожиданию ответа берет на себя фоновый поток Web API.

Но когда сервер пришлет ответ, этот ответ нужно как-то вернуть обратно в наш JavaScript-код, чтобы мы могли отрисовать данные. Web API не может просто вклиниться в Call Stack и прервать текущую работу движка. Вместо этого браузер использует систему очередей.

Две очереди: VIP и общий зал

Когда фоновая задача завершена, Web API помещает привязанный к ней коллбэк (функцию обратного вызова) в очередь ожидания. В браузере существует строгое разделение на две очереди, которые имеют разный приоритет.

Макрозадачи (Task Queue / Macrotask Queue)

Это исторически первая и основная очередь. Сюда попадают коллбэки от большинства асинхронных операций браузера. Что создает макрозадачи:

  • Таймеры: setTimeout, setInterval.
  • Пользовательские события: клики, скролл, ввод с клавиатуры (когда они обрабатываются асинхронно).
  • Сетевые события, операции с файлами.

Микрозадачи (Microtask Queue)

Эта очередь появилась позже, специально для обслуживания «обещаний» (Promises) и внутренних нужд движка. Микрозадачи — это VIP-очередь. Они имеют абсолютный приоритет над макрозадачами. Что создает микрозадачи:

  • Коллбэки промисов: .then(), .catch(), .finally().
  • Явное создание: queueMicrotask().
  • Наблюдатели за DOM: MutationObserver.

Цикл событий (Event Loop)

Event Loop — это бесконечный цикл внутри браузера, который координирует работу Call Stack, очередей и процесса рендеринга (Layout и Paint). Его алгоритм можно описать четырьмя строгими шагами, которые повторяются миллионы раз в секунду.

  1. Очистить Call Stack. Выполняется весь синхронный код. Event Loop ждет, пока стек вызовов не станет абсолютно пустым.
  2. Опустошить очередь микрозадач. Как только стек пуст, Event Loop смотрит в Microtask Queue. Он берет первую микрозадачу и отправляет ее в стек. Когда она завершается, он берет следующую. Важно: Event Loop не пойдет дальше, пока очередь микрозадач не станет полностью пустой. Если в процессе выполнения микрозадачи создаются новые микрозадачи, они добавляются в конец этой же очереди и будут выполнены в этом же цикле.
  3. Отрисовать кадр (Render). Если подошло время обновления экрана (те самые 16.6 мс для 60 FPS), браузер выполняет этапы Layout и Paint, обновляя пиксели на мониторе.
  4. Выполнить одну макрозадачу. Event Loop берет строго одну (первую) задачу из Macrotask Queue, отправляет ее в Call Stack и ждет ее завершения. После этого цикл возвращается к шагу 1.

Главное отличие: микрозадачи выполняются все разом до победного конца, а макрозадачи — строго по одной за итерацию цикла, уступая место рендерингу.

Практический разбор: классическая задача с собеседования

Понимание этого алгоритма — ключ к прохождению секции лайвкодинга. Рассмотрим классический сниппет:

console.log('1: Синхронный код');

setTimeout(() => {
  console.log('2: Макрозадача');
}, 0);

Promise.resolve().then(() => {
  console.log('3: Микрозадача');
});

console.log('4: Синхронный код');

Давайте проследим, как Event Loop обрабатывает этот код:

  1. Скрипт начинает выполняться (это первая глобальная макрозадача).
  2. Вызов console.log('1...'). Стек вызовов получает функцию, выводит текст в консоль, функция удаляется из стека.
  3. Вызов setTimeout. Web API принимает таймер. Поскольку задержка 0, Web API мгновенно отправляет коллбэк в Macrotask Queue.
  4. Вызов Promise.resolve().then(...). Коллбэк промиса отправляется в Microtask Queue.
  5. Вызов console.log('4...'). Вывод текста в консоль.
  6. Глобальный скрипт завершен. Call Stack пуст.
  7. Event Loop проверяет Microtask Queue. Там лежит коллбэк промиса. Он отправляется в стек, выполняется console.log('3...'). Очередь микрозадач пуста.
  8. Event Loop проверяет необходимость рендеринга.
  9. Event Loop проверяет Macrotask Queue. Там лежит коллбэк таймера. Он отправляется в стек, выполняется console.log('2...').

Итоговый вывод в консоль: 1, 4, 3, 2.

Понимание того, как Web APIs разгружают главный поток, а очереди микро- и макрозадач организуют порядок возвращения результатов, открывает путь к осознанному использованию асинхронности. В следующем шаге мы разберем, как именно Promise API использует микрозадачи для создания элегантного асинхронного кода, и что скрывается под синтаксическим сахаром async/await.

Эволюция асинхронности: от Promise API до скрытых механизмов Async/Await

Эволюция асинхронности: от Promise API до скрытых механизмов Async/Await

Мы уже знаем, что микрозадачи выполняются сразу после очистки Call Stack, опережая рендеринг и новые макрозадачи. Но откуда эти микрозадачи берутся? В современном JavaScript абсолютное большинство микрозадач порождается одним механизмом — промисами. Разработчики часто воспринимают промис просто как удобную обертку над сетевым запросом, но под капотом это строгий конечный автомат, который напрямую управляет тем, что и когда попадет в Microtask Queue.

Promise как конечный автомат

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

У промиса есть три состояния:

  1. Pending (ожидание) — начальное состояние. Асинхронная операция (например, запрос к Web API) еще выполняется.
  2. Fulfilled (исполнено) — операция завершилась успешно, промис получил значение.
  3. Rejected (отклонено) — операция завершилась ошибкой.

Состояния Fulfilled и Rejected объединяются термином Settled (завершенный).

Промис иммутабелен в контексте своего состояния: если он перешел в состояние Settled, его значение и статус фиксируются навсегда. Никакие последующие вызовы resolve() или reject() внутри него ничего не изменят.

Именно в момент перехода из Pending в Settled движок V8 берет коллбэки, привязанные к этому промису через .then() или .catch(), и отправляет их в Microtask Queue.

Анатомия цепочек (Chaining)

На собеседованиях часто дают задачи на чтение цепочек промисов. Чтобы не путаться в них, нужно усвоить один архитектурный принцип: каждый вызов .then() или .catch() всегда возвращает новый, совершенно независимый промис.

То, в какое состояние перейдет этот новый промис, зависит от того, что вернет функция внутри .then():

  • Если функция возвращает обычное значение (строку, число, объект), новый промис мгновенно переходит в состояние Fulfilled с этим значением.
  • Если функция выбрасывает ошибку (throw new Error()), новый промис переходит в состояние Rejected.
  • Если функция возвращает другой промис, новый промис «усваивает» его состояние и результат.

Благодаря этому механизму ошибки в цепочках «падают» вниз до ближайшего обработчика. Если второй .then() в цепочке из пяти ломается, третий и четвертый .then() просто вернут новые промисы в состоянии Rejected, не выполняя свой код, пока выполнение не дойдет до .catch().

Синтаксический сахар с двойным дном: Async/Await

Promise API избавил нас от «ада коллбэков», но код все еще оставался разорванным на блоки .then(). Стандарт ES2017 ввел async/await, позволив писать асинхронный код так, будто он синхронный.

Но здесь кроется главная ловушка для понимания: await не блокирует Main Thread. Если бы он это делал, страница бы зависала (превращаясь в Long Task), ожидая ответа от сервера.

Ключевое слово await делает нечто более хитрое: оно приостанавливает выполнение только текущей функции, сохраняя ее локальный контекст (переменные), и возвращает управление в Event Loop.

Концептуально, код с await автоматически нарезается движком на микрозадачи. Посмотрим на пример:

async function fetchUserData() {
  console.log('1. Начало запроса');
  const response = await fetch('/api/user');
  console.log('2. Данные получены');
  return response.json();
}

console.log('0. До вызова');
fetchUserData();
console.log('3. После вызова');

Под капотом V8 преобразует await в невидимый .then(). Все, что написано после await в этой функции, упаковывается в микрозадачу, которая будет добавлена в Microtask Queue только тогда, когда промис от fetch зарезолвится.

Порядок вывода в консоль будет таким: 0, 1, 3, 2. Вызов fetchUserData() синхронно доходит до первого await, запускает сетевой запрос (делегируя его Web APIs) и немедленно выходит из функции, позволяя Call Stack продолжить работу и вывести 3. И только спустя время, когда сеть ответит, остаток функции (2) попадет в очередь микрозадач.

Ловушка последовательного выполнения

Понимание того, как await ставит функцию на паузу, критически важно для производительности. Самая частая ошибка при работе с async/await — случайная сериализация независимых задач.

Представьте, что нам нужно получить данные двух независимых пользователей:

async function getFriends() {
  // Запрос 1
  const user1 = await fetch('/api/users/1');
  // Запрос 2 начнется ТОЛЬКО после завершения Запроса 1
  const user2 = await fetch('/api/users/2');

  return [user1, user2];
}

Поскольку await ставит выполнение функции getFriends на паузу, второй fetch даже не будет инициирован, пока не завершится первый. Если каждый запрос занимает 1 секунду, общее время выполнения составит: Ttotal=t1+t2T_{total} = t_1 + t_2 В нашем случае — 2 секунды.

Поскольку запросы не зависят друг от друга, их нужно запускать конкурентно. Для этого используется Promise.all:

async function getFriendsFast() {
  // Запускаем оба запроса синхронно, не дожидаясь ответов
  const req1 = fetch('/api/users/1');
  const req2 = fetch('/api/users/2');

  // Ждем завершения обоих промисов
  const [user1, user2] = await Promise.all([req1, req2]);

  return [user1, user2];
}

Здесь fetch вызывается дважды подряд в одном синхронном блоке кода. Оба запроса уходят в Web APIs практически одновременно. Promise.all создает новый промис, который перейдет в состояние Fulfilled только тогда, когда разрешатся все промисы в переданном массиве.

Теперь общее время выполнения равно времени самого долгого запроса: Ttotal=max(t1,t2)T_{total} = \max(t_1, t_2) В нашем случае — 1 секунда.

Мы увидели, что async/await — это элегантный фасад над Promise API и очередью микрозадач. Но сама способность JavaScript «поставить функцию на паузу», сохранить ее переменные в памяти, пойти выполнять другой код, а затем «снять с паузы» и продолжить с того же места — это не магия await. Этот механизм базируется на более низкоуровневой концепции движка — генераторах, которые мы разберем далее.

Генераторы и итераторы: управление потоком выполнения «на паузе»

Генераторы и итераторы: управление потоком выполнения «на паузе»

Мы выяснили, что под капотом async/await скрываются промисы и микрозадачи. Но остается один фундаментальный вопрос: как именно ключевое слово await заставляет функцию «замереть» посередине выполнения, не блокируя при этом Main Thread? В классическом JavaScript функция, попавшая в Call Stack, выполняется от первой до последней строчки. Нельзя просто поставить ее на паузу и пойти заниматься рендерингом.

Или можно? Механизм, позволяющий приостанавливать и возобновлять выполнение кода, существует. Это генераторы. Чтобы понять их, нам нужно спуститься на уровень ниже и разобраться с тем, как в JavaScript концептуально устроено пошаговое потребление данных.

Протокол итератора: контракт на пошаговость

Прежде чем ставить код на паузу, язык должен уметь запрашивать данные порциями. Для этого в спецификации ECMAScript описан протокол итератора.

Итератор — это любой объект, у которого есть метод next(). При каждом вызове этот метод должен возвращать объект строгой формы: { value: any, done: boolean }.

  • value — очередное значение.
  • done — флаг, сигнализирующий о том, что данные закончились.

Любой объект можно сделать итерируемым (например, чтобы его можно было перебрать в цикле for...of), если добавить ему специальный метод по ключу Symbol.iterator, который вернет объект-итератор.

const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;

    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (const num of range) {
  console.log(num); // 1, затем 2, затем 3
}

Здесь нет никакой магии остановки времени. Мы просто вызываем обычную функцию next(), которая благодаря замыканию помнит свое предыдущее состояние (current) и каждый раз возвращает новый результат.

Писать такие объекты вручную громоздко. Поэтому в язык добавили синтаксический сахар, который делает гораздо больше, чем просто генерирует объекты { value, done }.

Генераторы: функции, умеющие ждать

Генератор — это особая функция, которая объявляется со звездочкой function*. Внутри нее доступно ключевое слово yield (от англ. «уступать», «выдавать»).

function* numberGenerator() {
  console.log("Шаг 1");
  yield 1;
  console.log("Шаг 2");
  yield 2;
  return 3;
}

const iterator = numberGenerator();

Главный парадокс для новичков: вызов numberGenerator() не выполняет тело функции. В консоли не появится «Шаг 1». Вызов лишь создает и возвращает объект-итератор. Функция находится в состоянии Suspended (приостановлена на самом старте).

Выполнение начнется только при вызове iterator.next().

Анатомия паузы в Call Stack

Вспомним архитектуру V8. Когда обычная функция вызывается, для нее создается Stack Frame (кадр стека) в Call Stack. Когда функция завершается через return, кадр уничтожается.

С генераторами всё иначе:

  1. При вызове iterator.next() генератор помещается в Call Stack. Выполнение идет до первого yield.
  2. Ключевое слово yield 1 возвращает наружу объект { value: 1, done: false }.
  3. В этот момент кадр генератора удаляется из Call Stack, освобождая Main Thread. Но его локальные переменные и текущая позиция выполнения не уничтожаются — они сохраняются в Memory Heap (куче).
  4. При следующем вызове next() движок берет сохраненный контекст из кучи, снова создает кадр в Call Stack и продолжает выполнение ровно с той строчки, где остановился.

Именно yield позволяет функции добровольно уступить контроль над Main Thread, сохранив свое состояние в памяти.

Двусторонний обмен данными

Часто yield воспринимают только как аналог return, возвращающий данные наружу. Но это двусторонний портал. Метод next() может принимать аргумент, который будет передан внутрь генератора и станет результатом выражения yield.

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

function* mathGenerator() {
  // Выполнение остановится ЗДЕСЬ, ожидая значения от следующего next()
  const a = yield "Введите первое число";
  const b = yield "Введите второе число";
  return a + b;
}

const gen = mathGenerator();

// Первый вызов ВСЕГДА без аргументов. Он запускает генератор до первого yield.
console.log(gen.next());
// { value: 'Введите первое число', done: false }

// Передаем 10. Эта десятка становится результатом ПЕРВОГО yield (переменная a = 10).
// Генератор идет до второго yield и останавливается.
console.log(gen.next(10));
// { value: 'Введите второе число', done: false }

// Передаем 5. Она становится результатом ВТОРОГО yield (переменная b = 5).
// Генератор доходит до return a + b (10 + 5).
console.log(gen.next(5));
// { value: 15, done: true }

Правило сдвига: аргумент, переданный в next(X), подставляется на место того yield, на котором генератор был приостановлен в прошлый раз, а не того, до которого он дойдет сейчас.

Синтез: как генераторы и промисы рождают Async/Await

Теперь мы подошли к кульминации. У нас есть промисы (глава 3), которые умеют асинхронно ждать результат и сообщать о готовности через микрозадачи. И у нас есть генераторы, которые умеют ставить функцию на паузу и принимать значения снаружи через next(value).

Если объединить их, мы получим точную копию механизма async/await.

Представьте, что async/await не существует. Как написать асинхронный код, который выглядит как синхронный? Мы напишем генератор, который будет yield-ить промисы, а специальная функция-раннер будет их перехватывать, ждать разрешения, и прокидывать результат обратно в генератор.

// Наш асинхронный код (выглядит синхронно!)
function* fetchUserData() {
  try {
    // Уступаем промис наружу. Когда он выполнится, раннер вернет нам ответ.
    const user = yield fetch('/api/user');
    const posts = yield fetch(`/api/posts/${user.id}`);
    console.log("Данные загружены:", posts);
  } catch (error) {
    console.error("Ошибка:", error);
  }
}

Чтобы это заработало, напишем функцию spawn — упрощенный аналог того, что делает движок JS под капотом при встрече с async:

function spawn(generatorFunc) {
  const iterator = generatorFunc();

  function step(nextValue) {
    // Делаем шаг генератора, передавая ему результат предыдущего промиса
    const result = iterator.next(nextValue);

    if (result.done) {
      return; // Генератор завершил работу
    }

    // result.value — это промис, который мы за-yield-или
    Promise.resolve(result.value)
      .then(resolvedValue => {
        // Промис выполнился -> возвращаем данные обратно в генератор
        step(resolvedValue);
      })
      .catch(error => {
        // Промис отклонен -> прокидываем ошибку внутрь генератора
        iterator.throw(error);
      });
  }

  // Запускаем первый шаг
  step();
}

// Запуск
spawn(fetchUserData);

Когда вы пишете async function, движок V8 неявно оборачивает ее тело в генератор, а вызовы await превращает в yield. Встроенный в движок микро-раннер (подобный нашему spawn) берет на себя подписку на .then() и вызовы .next().

Именно поэтому await не блокирует Main Thread. Он просто делает yield промиса, кадр функции удаляется из Call Stack, и Event Loop продолжает обрабатывать другие задачи и рендеринг. Когда промис резолвится, микрозадача вызывает next(результат), и функция восстанавливается из кучи, продолжая работу.

Понимание этой механики дает полный контроль над асинхронным потоком. Однако в реальных проектах на React мы редко пишем сырые генераторы (за исключением библиотек вроде Redux Saga). Зато мы постоянно описываем сложные асинхронные контракты данных. И здесь на сцену выходит TypeScript. Как правильно типизировать функции, которые возвращают промисы, зависящие от входных параметров? Об этом — в следующем шаге.

Продвинутый TypeScript: Generic-типы и ограничения (Constraints)

Продвинутый TypeScript: Generic-типы и ограничения (Constraints)

В прошлой главе мы реализовали функцию-раннер spawn, которая принимает генератор и управляет его выполнением. Эта функция максимально универсальна: она может работать с любыми данными, любыми промисами и возвращать любой результат. В JavaScript такая гибкость естественна. Но как только мы переносим этот код в TypeScript, возникает парадокс: как строго типизировать функцию, главная цель которой — работать с любыми типами данных?

Если мы жестко укажем типы, функция потеряет универсальность. Если используем any, мы отключим компилятор и потеряем смысл использования TypeScript. Нам нужен механизм, который позволяет передавать типы как аргументы.

Переменные для типов

В JavaScript мы используем аргументы функций, чтобы не писать один и тот же код для разных значений. В TypeScript мы используем дженерики (Generics), чтобы не писать одни и те же интерфейсы для разных типов данных. Дженерик — это переменная, которая хранит тип, а не значение.

Рассмотрим классическую задачу: мы пишем обертку для ответов от API. Сервер всегда возвращает статус и данные, но сами данные могут быть списком пользователей, объектом профиля или просто строкой.

// T — это переменная типа. Мы объявляем её в угловых скобках <T>.
interface ApiResponse<T> {
  status: number;
  data: T;
  error?: string;
}

// Теперь мы можем "вызывать" этот интерфейс, передавая ему конкретный тип:
const userResponse: ApiResponse<{ name: string }> = {
  status: 200,
  data: { name: "Alice" }
};

Дженерики работают и на уровне функций. Когда функция принимает аргумент неизвестного заранее типа, и этот же тип возвращается или используется внутри, мы объявляем переменную типа перед параметрами.

function wrapInArray<T>(value: T): T[] {
  return [value];
}

const numbers = wrapInArray(42); // TS автоматически понимает, что T — это number, результат: number[]
const strings = wrapInArray("hello"); // T становится string, результат: string[]

Дженерик (Generic) — это параметр типа, позволяющий компонентам (функциям, классам, интерфейсам) работать с различными типами данных, сохраняя при этом строгую проверку типов во время компиляции.

Важно понимать природу дженериков: они существуют только в воображении компилятора TypeScript.

Сравнение подходов к неизвестным данным

Почему нельзя просто использовать встроенные типы для неизвестных данных? Посмотрим на разницу:

Подход Поведение компилятора Безопасность Когда использовать
any Отключает проверку типов. Разрешает любые операции. Нулевая. Ошибки всплывут только в рантайме. При миграции старого JS-кода (временно).
unknown Требует явной проверки типа (Type Guard) перед использованием. Высокая, но требует написания дополнительных проверок. Когда мы действительно не знаем, что придет (например, JSON.parse).
Generic (<T>) Связывает типы между собой. "То, что вошло, то и выйдет". Максимальная. Сохраняет исходный тип без дополнительных проверок. Когда логика функции не зависит от конкретного типа, но тип нужно сохранить.

Проблема абсолютной свободы

Дженерики в чистом виде дают абсолютную свободу. Переменная T может быть чем угодно: числом, строкой, сложным объектом, null. И в этом кроется проблема.

Допустим, мы пишем функцию, которая должна вернуть длину переданного элемента.

function getLength<T>(item: T): number {
  // Ошибка компиляции: Property 'length' does not exist on type 'T'.
  return item.length;
}

Компилятор TypeScript мыслит категориями гарантий. Поскольку T может быть абсолютно любым, кто-то может передать число 42. У числа нет свойства length, код упадет в рантайме. TypeScript справедливо запрещает эту операцию, так как не может гарантировать безопасность.

Нам нужно сказать компилятору: «T может быть любым типом, но при условии, что у этого типа есть свойство length».

Ограничения (Constraints) через extends

Для сужения свободы дженериков используется ключевое слово extends. В контексте дженериков оно означает ограничение (Constraint) и читается как «должен соответствовать форме».

С точки зрения теории множеств, запись T extends U означает, что множество свойств типа T включает в себя все свойства типа U (то есть TUT \supseteq U). Тип T может быть шире и иметь дополнительные свойства, но он обязан иметь как минимум то, что описано в U.

Исправим нашу функцию:

interface HasLength {
  length: number;
}

// T может быть любым типом, который содержит как минимум свойство length типа number
function getLength<T extends HasLength>(item: T): number {
  return item.length; // Теперь TS спокоен, он точно знает, что length существует
}

getLength("hello"); // Работает (у строк есть length)
getLength([1, 2, 3]); // Работает (у массивов есть length)
getLength({ length: 10, name: "Box" }); // Работает (объект удовлетворяет HasLength)
// getLength(42); // Ошибка: Argument of type 'number' is not assignable to parameter of type 'HasLength'

Обратите внимание: мы не просто указали аргумент item: HasLength. Если бы мы сделали так, функция забыла бы исходный тип и считала бы, что вернулся просто объект с length. Использование <T extends HasLength> позволяет нам сохранить исходный тип для дальнейшего использования, одновременно гарантируя наличие нужного свойства для внутренней логики.

Связывание типов: оператор keyof

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

Представьте функцию getProperty, которая извлекает значение из объекта по ключу. В JavaScript это выглядит так: const val = obj[key]. Как типизировать это так, чтобы TypeScript не позволял запросить несуществующий ключ и точно знал тип возвращаемого значения?

Нам понадобятся два дженерика и оператор keyof.

Оператор keyof берет тип объекта и возвращает объединение (Union) строковых или числовых литералов, представляющих ключи этого объекта.

Например, если у нас есть тип пользователя:

interface User {
  id: number;
  email: string;
}
// keyof User эквивалентно типу "id" | "email"

Теперь свяжем объект и его ключ в функции:

// 1. T — это тип нашего объекта.
// 2. K — это тип ключа. K ограничен ключами объекта T.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, email: "test@example.com" };

// TS видит, что T — это тип объекта user.
// Следовательно, K обязан быть "id" | "email".
const id = getProperty(user, "id"); // Тип id — number
const email = getProperty(user, "email"); // Тип email — string

// getProperty(user, "password"); // Ошибка: Argument of type '"password"' is not assignable to parameter of type '"id" | "email"'.

В этом примере T[K] — это Indexed Access Type (тип по индексу). Он говорит компилятору: «посмотри, какой тип лежит в объекте T по ключу K, и верни именно его». Мы связали входной объект, допустимые ключи и тип результата в единый строгий контракт.

Практический синтез: типизация кэширующей функции

Давайте объединим наши знания о дженериках и ограничениях с контекстом асинхронности из предыдущих глав. Напишем сигнатуру функции, которая запрашивает данные и кэширует их.

Нам нужно, чтобы функция:

  1. Принимала ключ кэша (строку).
  2. Принимала асинхронную функцию (которая возвращает Promise), чтобы выполнить её, если кэш пуст.
  3. Возвращала Promise с тем же типом данных, который возвращает переданная функция.
// Описываем форму функции-провайдера данных
type Fetcher<T> = () => Promise<T>;

// Функция принимает дженерик T, который представляет тип данных
async function fetchWithCache<T>(cacheKey: string, fetcher: Fetcher<T>): Promise<T> {
  const cached = localStorage.getItem(cacheKey);
  if (cached) {
    return JSON.parse(cached) as T; // Возвращаем из кэша, приводя к типу T
  }

  const data = await fetcher(); // Вызываем провайдер, он возвращает T
  localStorage.setItem(cacheKey, JSON.stringify(data));
  return data;
}

Теперь, вызывая эту функцию, мы получаем строгую типизацию всего потока данных без дублирования кода:

interface Article { title: string; views: number }

// TS автоматически выводит T как Article из возвращаемого значения fetcher
const article = await fetchWithCache("article_1", async () => {
  const res = await fetch("/api/articles/1");
  return res.json() as Promise<Article>;
});

// article строго типизирован как Article
console.log(article.views);

Дженерики и ограничения — это фундамент для создания переиспользуемых и безопасных абстракций. Они позволяют TypeScript понимать наши намерения даже в самых динамичных паттернах JavaScript. Однако иногда нам нужно не просто ограничить тип, но и преобразовать его в зависимости от условий (например, снять Promise с типа или сделать все поля опциональными). Эти задачи решаются с помощью условных и отображаемых типов, к которым мы перейдем далее.

Метапрограммирование в типах: Conditional Types и Mapped Types

Метапрограммирование в типах: Conditional Types и Mapped Types

Система типов TypeScript — это не просто инструмент для статической проверки, это полноценный функциональный язык программирования, работающий на этапе компиляции. В JavaScript мы используем if/else для ветвления логики и Array.prototype.map() для преобразования данных. В TypeScript мы можем делать то же самое, но не со значениями, а с самими типами.

Мы перестаем просто описывать структуры данных и начинаем писать код, который генерирует новые типы на основе существующих. Это и есть метапрограммирование.

Условные типы: ветвление логики на этапе компиляции

Ранее мы использовали ключевое слово extends для ограничения дженериков (Constraints), указывая, что тип должен соответствовать определенной структуре. Но у extends есть и вторая роль. Если использовать его в связке с тернарным оператором, он превращается в условие:

T extends U ? X : Y

Это читается так: если тип T можно присвоить типу U, то возвращаем тип X, иначе возвращаем тип Y.

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // type A = true
type B = IsString<42>;      // type B = false

На первый взгляд, это кажется избыточным. Зачем проверять тип, если мы и так знаем, что передаем? Настоящая сила условных типов раскрывается при работе с объединениями (Union Types).

Когда условный тип принимает объединение, он применяется к каждому элементу этого объединения по отдельности. Это называется дистрибутивностью.

type ToArray<T> = T extends any ? T[] : never;

// TypeScript разобьет объединение и применит ToArray к каждой части:
// ToArray<string> | ToArray<number>
type StringOrNumberArray = ToArray<string | number>;
// Результат: string[] | number[]

Ключевое слово infer: извлечение типов

Условные типы позволяют не только проверять соответствие, но и «вытаскивать» внутренние типы из сложных структур с помощью ключевого слова infer (от англ. infer — делать вывод).

infer работает как захват переменной в регулярных выражениях. Мы говорим компилятору: «Если тип совпадает с этим шаблоном, пожалуйста, сохрани вот эту его часть во временную переменную типа, чтобы я мог ее вернуть».

Самый практичный пример — извлечение типа значения, которое резолвит Promise.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type ResponseData = UnwrapPromise<Promise<string>>; // type ResponseData = string
type PlainData = UnwrapPromise<number>;             // type PlainData = number

В этом коде TypeScript проверяет: является ли T промисом? Если да, он берет то, что находится внутри угловых скобок промиса, называет это U и возвращает. Если T не является промисом, он просто возвращает исходный тип T.

Этот паттерн лежит в основе встроенного в TypeScript типа Awaited<T>, который рекурсивно разворачивает любые цепочки промисов, имитируя поведение await на уровне типов.

Отображаемые типы (Mapped Types): массовая трансформация

Если условные типы — это аналог if/else, то отображаемые типы — это аналог цикла for...in или метода map(). Они позволяют взять существующий тип объекта и перебрать все его ключи, чтобы создать новый тип.

Синтаксис опирается на оператор keyof, который мы уже разбирали: [K in keyof T].

interface User {
  id: number;
  name: string;
  email: string;
}

// Создаем тип, где все свойства User становятся булевыми
type UserValidationFlags = {
  [K in keyof User]: boolean;
};
/*
Результат:
{
  id: boolean;
  name: boolean;
  email: boolean;
}
*/

Управление модификаторами

В процессе итерации по ключам мы можем добавлять или удалять модификаторы свойств: readonly (только для чтения) и ? (необязательное свойство). Добавление происходит просто указанием модификатора, а удаление — с помощью префикса минус -.

Например, встроенный тип Partial<T> делает все свойства необязательными. Вот как он устроен под капотом:

type MyPartial<T> = {
  [K in keyof T]?: T[K]; // Добавляем ? к каждому ключу
};

А если мы хотим создать тип, который, наоборот, снимает все ограничения readonly с переданного объекта, мы используем минус:

type Mutable<T> = {
  -readonly [K in keyof T]: T[K]; // Удаляем readonly
};

Key Remapping: переименование ключей на лету

Начиная с TypeScript 4.1, мы можем не только менять типы значений, но и трансформировать сами названия ключей прямо в процессе итерации. Для этого используется ключевое слово as внутри квадратных скобок.

Это позволяет создавать мощные фабрики типов. Представьте, что у нас есть интерфейс состояния, и мы хотим автоматически сгенерировать тип для объекта, который содержит геттеры для каждого свойства этого состояния.

interface State {
  theme: string;
  isSidebarOpen: boolean;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type StateGetters = Getters<State>;
/*
Результат:
{
  getTheme: () => string;
  getIsSidebarOpen: () => boolean;
}
*/

Здесь происходит сразу несколько вещей:

  1. Мы проходим по ключам T.
  2. Ключевое слово as перехватывает оригинальное имя ключа K.
  3. Мы используем шаблонные литералы типов (Template Literal Types) `get${...}` для создания новой строки.
  4. Встроенный тип Capitalize делает первую букву заглавной.
  5. Значением становится функция, возвращающая оригинальный тип свойства T[K].

Теперь, комбинируя дженерики, условные типы, infer и отображаемые типы, мы можем описать контракты любой сложности. Мы можем заставить TypeScript понимать, как именно меняются данные при прохождении через слои архитектуры или асинхронные вызовы.

Практический синтез: типизация асинхронных паттернов и API

Практический синтез: типизация асинхронных паттернов и API

В реальных проектах мы редко вызываем голый fetch напрямую и еще реже вручную прописываем дженерики вроде fetchWithCache<User>('/api/user') при каждом запросе. Человеческий фактор неизбежен: рано или поздно кто-то передаст URL /api/posts, но забудет поменять дженерик, и TypeScript радостно пропустит ошибку, считая посты пользователями.

Настоящая мощь TypeScript раскрывается, когда мы заставляем компилятор вычислять типы самостоятельно на основе конфигурации. В этой главе мы объединим знания об асинхронности движка V8 с инструментами метапрограммирования в типах, чтобы построить пуленепробиваемый слой работы с данными.

Умный API-клиент: связываем URL и ответ

Представьте, что у нас есть централизованный конфиг всех эндпоинтов приложения. Мы хотим написать функцию api.get(), которая принимает строку URL и магическим образом возвращает Promise со строгим типом данных, соответствующим именно этому URL.

Для начала опишем «карту» нашего API с помощью обычного интерфейса:

interface ApiEndpoints {
  '/users/me': User;
  '/posts': Post[];
  '/settings': AppSettings;
}

Теперь нам нужно создать функцию get. Ее задача — принять строку, но не любую, а только ту, что есть в ключах ApiEndpoints. И вернуть Promise, тип которого зависит от переданной строки.

Здесь мы комбинируем дженерик, ограничение (Constraint) и Indexed Access Type:

class ApiClient {
  // T ограничено ключами нашего интерфейса
  async get<T extends keyof ApiEndpoints>(url: T): Promise<ApiEndpoints[T]> {
    const response = await fetch(url);
    if (!response.ok) throw new Error('Network response was not ok');
    return response.json();
  }
}

const api = new ApiClient();

Как только мы это написали, IDE начинает работать на нас. При вызове api.get('/users/me') TypeScript подставляет строку '/users/me' на место T. Затем он вычисляет возвращаемый тип: Promise<ApiEndpoints['/users/me']>, что мгновенно превращается в Promise<User>.

Извлечение полезной нагрузки: типизация Thunk-экшенов

Перейдем к слою управления состоянием. В архитектурах, использующих паттерны вроде Redux Thunk или сложных сервисов, у нас есть множество асинхронных функций, которые ходят в сеть.

const fetchUserProfile = async (id: number) => {
  const data = await api.get('/users/me');
  return data; // тип User
};

const fetchUserPosts = async (id: number) => {
  const data = await api.get('/posts');
  return data; // тип Post[]
};

Часто нам нужно получить тип данных, который эта функция вернет после того, как Promise зарезолвится. Например, чтобы типизировать payload в редьюсере. Писать типы вручную — значит дублировать код. Мы можем заставить TypeScript «заглянуть» внутрь функции и достать тип из Promise.

Для этого мы конструируем условный тип (Conditional Type) с использованием infer. Наш тип должен ответить на вопрос: «Является ли переданный тип функцией, возвращающей Promise? Если да, вытащи то, что внутри Promise, иначе верни ошибку».

type ExtractAsyncPayload<T> = T extends (...args: any[]) => Promise<infer R>
  ? R
  : never;

Разберем анатомию этого заклинания:

  1. T extends (...args: any[]) => ... — мы проверяем, является ли T функцией с любыми аргументами.
  2. Promise<infer R> — мы требуем, чтобы функция возвращала Promise, и просим TypeScript захватить внутренний тип этого промиса во временную переменную R.
  3. ? R : never — если условие выполнено, возвращаем захваченный тип R. Если переданный тип не является асинхронной функцией, возвращаем never (тип, означающий невозможное состояние).

Теперь мы можем получить чистый тип данных из любой асинхронной функции:

// Тип UserData будет строго равен User
type UserData = ExtractAsyncPayload<typeof fetchUserProfile>;

// Тип PostsData будет строго равен Post[]
type PostsData = ExtractAsyncPayload<typeof fetchUserPosts>;

Абсолютный синтез: генерация слоя хуков

Вершина типизации асинхронных паттернов — это автоматическая генерация производных типов. Допустим, у нас есть объект со всеми API-запросами приложения. Мы хотим на основе этого объекта сгенерировать тип для кастомных React-хуков, которые будут возвращать данные или null (пока идет загрузка).

Исходный объект:

const apiServices = {
  getUser: async () => ({ id: 1, name: 'Alice' }),
  getPosts: async () => ([{ id: 101, title: 'Hello' }]),
};

Мы хотим получить тип, который выглядит так:

type ExpectedHooks = {
  useGetUser: () => User | null;
  useGetPosts: () => Post[] | null;
};

Здесь нам потребуется весь наш арсенал: Mapped Types для итерации по объекту, Key Remapping для переименования ключей (добавления префикса use и изменения регистра) и наш паттерн с infer для распаковки промисов.

type CreateHooks<T> = {
  // Итерируемся по ключам и переименовываем их
  [K in keyof T as `use${Capitalize<string & K>}`]:
    // Извлекаем тип из Promise и оборачиваем в функцию, возвращающую данные или null
    T[K] extends (...args: any[]) => Promise<infer R>
      ? () => R | null
      : never;
};

// Применяем магию
type AppHooks = CreateHooks<typeof apiServices>;

Что здесь происходит:

  1. K in keyof T перебирает ключи getUser и getPosts.
  2. Оператор as выполняет Key Remapping. Шаблонный литерал `use${Capitalize<string & K>}` берет строку getUser, делает первую букву заглавной (GetUser) и приклеивает use. Получается useGetUser.
  3. В значении мы проверяем оригинальную функцию T[K]. Если она возвращает Promise<infer R>, мы конструируем новый тип функции: () => R | null.

Такой подход позволяет архитектуре масштабироваться безопасно. Если backend-разработчик добавит новый метод в apiServices или изменит структуру ответа, TypeScript автоматически обновит типы для всех производных хуков, экшенов и компонентов по всему приложению. Ошибки несовпадения контрактов будут пойманы еще до того, как код попадет в браузер и дойдет до движка V8.