Перейти к содержанию

Типы асинхронных функций

В предыдущем уроке чтение строки каталога возвращало либо проверенные курсы, либо описанную ошибку. Операция была синхронной. Теперь сохраним тот же внутренний результат, но получим его через Promise. Это позволит отличить тип обещания от типа значения после await и не перепутать обычный отказ схемы с отклонённым обещанием.

Данные остаются прежними: четыре курса, сумма 60. В этом уроке нет сетевого запроса и измеряемой задержки. Асинхронная граница создана через Promise.resolve, чтобы рассмотреть отношения типов отдельно от HTTP. Полные независимые исходники lesson-17 находятся в архиве продолжения. Компилятор и программы при подготовке не запускались; ниже описаны ожидаемые результаты.

Promise содержит результат, а не заменяет его

Полезно прочитать подпись по слоям. Course[] описывает набор данных. Result<readonly Course[], CatalogError> описывает успешный набор или предметный отказ. Promise<Result<...>> сообщает, что этот результат появится после асинхронного завершения. Каждый слой имеет собственное назначение и не отменяет остальные.

В новом src/result.ts вынесены общие объявления. Помимо syntax/schema предусмотрены категории будущего загрузчика, но первая операция их не создаёт:

export type Result<T, E> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };

export type CatalogError =
  | { readonly kind: 'syntax' | 'schema' | 'network' | 'media'; readonly message: string }
  | { readonly kind: 'http'; readonly message: string; readonly status: number };

Поле ok остаётся дискриминатором. Тип ошибки HTTP содержит дополнительный статус, остальные ошибки имеют свою категорию и сообщение. Мы не возвращаем объект HTTP из локального чтения только потому, что такая форма предусмотрена общим типом. Реализация операции определяет, какие исходы действительно встречаются.

src/reader.ts содержит полный прежний механизм чтения и новую асинхронную функцию:

import { parseCourses } from './model.js';
import type { Course } from './model.js';
import type { Result, CatalogError } from './result.js';

export function readCatalog(source: string): Result<readonly Course[], CatalogError> {
  let external: unknown;
  try { external = JSON.parse(source); }
  catch { return { ok: false, error: { kind: 'syntax', message: 'Текст не является JSON' } }; }
  try { return { ok: true, value: parseCourses(external) }; }
  catch (error: unknown) {
    return { ok: false, error: { kind: 'schema', message: error instanceof Error ? error.message : 'Ошибка схемы' } };
  }
}

export async function readAsync(source: string): Promise<Result<readonly Course[], CatalogError>> {
  const text = await Promise.resolve(source);
  return readCatalog(text);
}

Вызов readAsync сразу возвращает Promise, а не массив курсов. Его return внутри async-функции возвращает обычный Result; среда помещает значение в успешное завершение обещания. await у вызывающей стороны получает именно внутренний Result. Не нужно вручную создавать второй Promise вокруг уже асинхронной функции.

Тип возвращаемого значения асинхронной функции описывается как Promise, что показано в More on Functions. Наш собственный Result задаёт дополнительный прикладной договор внутри этого обещания.

Ошибка схемы не обязана быть reject

Если строка содержит незакрытую скобку, readCatalog возвращает ok: false. Асинхронная оболочка при этом нормально завершается этим значением. Поэтому один catch вокруг await не заменяет проверку result.ok: Promise успешно принёс предметный отказ.

Это важное различие для интерфейса. Получить HTTP-ответ с неправильными данными и потерять соединение — разные события. Некоторые проекты выражают оба случая исключениями, другие возвращают Result для ожидаемых отказов. Типы должны соответствовать выбранной реализации. Нельзя описать Result в сигнатуре и затем забыть обработать его неуспешную ветку.

Полный src/app.ts показывает обе категории завершения:

import { courses } from './data.js';
import { readAsync } from './reader.js';

async function main(): Promise<void> {
  const result = await readAsync(JSON.stringify(courses));
  console.log(result.ok ? `Принято курсов: ${result.value.length}` : result.error.message);
  const failed = await readAsync('{');
  console.log(failed.ok ? 'Успех' : failed.error.kind);
  try { await Promise.reject(new Error('Отдельный reject')); }
  catch (error: unknown) { console.log(error instanceof Error ? error.message : 'Неизвестный reject'); }
}

void main().catch(() => console.log('Непредвиденная ошибка main'));

Ожидаются строки «Принято курсов: 4», «syntax» и «Отдельный reject». Первые два сообщения относятся к значениям Result. Третье получено из отдельного сознательно отклонённого Promise и обработано catch. Оно не является дополнительной ошибкой исходных четырёх курсов.

В catch значение начинается с unknown. JavaScript допускает отклонение любым значением, поэтому чтение message требует проверки instanceof Error. Статический Promise<T> не содержит отдельного параметра типа исключения, который заставил бы каждый reject иметь форму CatalogError. Мы не делаем такую гарантию автоматически.

Кто отвечает за окончание main

Функция main возвращает Promise<void>: она сообщает ожидаемые сообщения и не предоставляет вызывающему коду полезное значение. Верхний .catch создаёт границу для неожиданного отказа самой последовательности. Это не тот же catch, который показывает специально созданный reject внутри примера.

Запись void main().catch(...) обозначает, что итоговое обещание не используется дальше. Сам оператор void не ловит ошибки. Обработка происходит благодаря добавленному .catch; если убрать его, один void main() не превращает неожиданный отказ в безопасное завершение. Такой нюанс особенно важен у событий DOM, которые не ожидают Promise обработчика.

Если main случайно начнёт возвращать число вместо завершения без полезного значения, явная подпись поможет заметить изменение договора. Но тип не докажет, что каждое сообщение показано в нужном месте. Порядок операций определяется последовательными await и обычными строками тела функции.

В текущем коде второй вызов начинается после завершения первого. Чтобы запустить независимые операции одновременно, понадобился бы другой сценарий, например сохранение нескольких Promise и последующее совместное ожидание. Это изменило бы порядок работы, а не только оформление типов. В этом уроке параллельность специально не добавляется.

Подпись одной операции и вызывающий код

Рассмотрим обещание без немедленного ожидания. Если сохранить const pending = readAsync(source), у pending будет тип Promise результата. Его нельзя использовать так, словно это уже объект с ok и value. Отношение становится другим только после await pending: локальная переменная получает Result, и тогда дискриминатор выбирает нужную ветку.

В таком примере полезно назвать переменные по их роли. Pending обозначает незавершённое получение, result — принятое значение. Названия не усиливают типы, но помогают заметить место, где код пересекает асинхронную границу. У большого обработчика с несколькими операциями эта разница облегчает чтение больше, чем лишняя аннотация у каждой строки.

Если убрать await внутри readAsync, но сохранить async и вернуть readCatalog(source), вызывающая сторона всё равно получит Promise. Таким образом, наличие слова async само задаёт асинхронную форму результата, а внутренний await показывает конкретное ожидание. В нашей версии он оставлен для обучения переходу от Promise к string.

Если же readCatalog неожиданно бросит исключение за пределами своих обработанных условий, async-функция может завершиться отклонением. Поэтому Promise<Result<...>> нельзя читать как доказательство отсутствия reject. В одном типе T перечислены успешные значения обещания; правила ожидаемых ошибок мы дополнительно реализовали внутри операции.

Наконец, ожидание Promise не является отменой. В каталоге ещё нет AbortSignal и договора прерывания. Если интерфейс закроется раньше завершения настоящей загрузки, одного типа Promise будет недостаточно, чтобы прекратить работу или запретить запись в устаревший компонент. Такую политику нужно добавлять отдельно после появления реального жизненного цикла.

Где ожидание не создаёт новых гарантий

Операция await Promise.resolve(source) не доказывает безопасность входного текста. После ожидания строка всё ещё проходит JSON.parse и runtime-парсер. Если написать Promise<Course[]> после произвольного JSON-приведения, асинхронность не устранит ложное доверие к схеме. Типовой и исполняемый этапы остаются разделёнными.

Так же Promise не копирует переданный объект. Если бы внутренним результатом была общая изменяемая модель, её состояние могло бы измениться через другую ссылку. Наш парсер создаёт новые Course-объекты из проверенных полей, а readonly сообщает ограничения обычному TypeScript-потребителю. Физическое замораживание по-прежнему не выполняется.

Для отсутствующего курса или пустого набора тип результата следует выбирать по предметному смыслу. В чтении [] — успешный каталог из нуля записей. В поиске конкретной обязательной записи отсутствие может стать собственной ошибкой. Promise лишь переносит выбранный результат во времени и не выбирает эту политику.

Наконец, указанная подпись не обещает скорость. Здесь нет таймера, сервера и измерений. Даже настоящая async-функция может долго выполнять синхронную работу до первого ожидания. Поэтому слово «асинхронная» не следует превращать в утверждение об отсутствии блокировки любой операции.

Проследите путь неверной строки: Promise завершается, await получает Result, проверка ok выбирает ошибку syntax. Затем отдельно проследите reject: управление переходит к catch. Теперь два пути различимы и в типах, и в тексте. В следующем уроке применим проверенную модель к DOM и уточним, почему найденный элемент тоже может отсутствовать.

Оглавление курса.