Главная / Статьи

Шесть техник TypeScript, превращающих типы в реальный инструмент предотвращения ошибок

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

Издатель: REACTAPP.TOP7 мин чтения1627 слов

Источники: TypeScript Can Already Do This. Almost Nobody Uses It.

Проект может указать TypeScript в package.json, определить интерфейсы с дюжиной необязательных полей, но всё равно падать с ошибкой «нельзя читать свойства undefined». Наличие установленного компилятора — это не то же самое, что его работоспособность для вас, а добавление : string к каждой переменной само по себе мало чего даёт. Шесть методов, описанных ниже, используются опытными командами для того, чтобы проверка типов действительно играла важную роль; к каждому методу приведён фрагмент кода и обоснование его использования.

Проверка объектов с помощью satisfies вместо аннотаций

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

const config: Record<string, number> = {
  timeout: 3000,
  retries: 3,
};
config.timeout.toFixed(2); // fine

Этот код компилируется, но аннотация заменяет то, что TypeScript пытался определить автоматически. Тип переменной теперь точно равен Record<string, number>, поэтому компилятор больше не знает, какие ключи на самом деле имеется у объекта. config.timeout представляет собой просто «некое число под неким строковым ключом», опечатка вроде config.timout принимается без замечаний, а при включенном режиме noUncheckedIndexedAccess каждое прочитанное свойство становится типа number | undefined.

Оператор satisfies, доступный с версии TypeScript 4.9, проверяет объект на соответствие типу, не заменяя при этом автоматически определённый тип:

const config = {
  timeout: 3000,
  retries: 3,
} satisfies Record<string, number>;

config.timeout; // TypeScript still knows this is literally 3000

Проводится проверка корректности данных (строковые значения отклоняются), и сохраняется точная структура объекта: TypeScript знает, что в объекте обязательно присутствуют поля timeout и retries, а неправильно написанные ключи приводят к ошибкам. Одно уточнение по распространённому утверждению, включая комментарий из примера кода: само слово satisfies не обеспечивает сохранения типа литерала 3000 для поля timeout; свойства изменяемого объекта всё равно преобразуются в тип number. Если необходимы также сами литеральные значения, следует объединить оба оператора в формате as const satisfies Record<string, number>, что делает объект只读 и сохраняет все литералы. Многие разработчики по-прежнему используют эту аннотацию просто потому, что на протяжении многих лет это был единственный вариант.

Варианты моделей как маркированные союзы, а не факультативные поля

Когда значение может принимать несколько форм, соблазнительным упрощением является сделание каждого поля необязательным:

interface ApiResponse {
  data?: User[];
  error?: string;
  loading?: boolean;
}

Теперь этот тип позволяет любую комбинацию, включая одновременное наличие data и error, или отсутствие обоих при значении loading равном false. Правило о том, что в любой момент времени может существовать только одно состояние, находится в головах людей, и компилятор не может его обеспечить.

Дискриминированное объединение (объединение с метками) кодирует это правило. Каждый вариант содержит литеральную метку status и только те поля, которые к нему относятся:

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />; // data exists, guaranteed
    case 'error':
      return <ErrorBanner message={response.error} />;
  }
}

Внутри каждого case TypeScript ограничивает значение переменной response соответствующей вариантой. Доступ к response.data возможен только в ветке success, поскольку у остальных вариантов вообще нет свойства data. Целая категория ошибок типа «это определено здесь?» исчезает ещё до запуска кода, причем тот же принцип работает и для действий редьюсера, состояний формы и результатов API.

Заставьте отсутствующие случаи приводить к сбою с помощью конструкции never

У объединений с метками остается одна проблема. Предположим, что спустя несколько месяцев кто-то добавит статус 'forbidden'. Ничто не заставляет обновлять каждый оператор switch для типа ApiResponse, поэтому новый случай проходит незамеченным и приводит к ошибке в продакшене.

Проверка полноты закрывает эту пробел. Ветвь default присваивает суженное значение переменной типа never:

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />;
    case 'error':
      return <ErrorBanner message={response.error} />;
    default:
      const exhaustiveCheck: never = response;
      throw new Error(`Unhandled status: ${exhaustiveCheck}`);
  }
}

Когда обработаны все варианты, для response в ветви default больше ничего не остается, поэтому её тип — never, и присваивание компилируется. Если добавить 'forbidden' без соответствующего case, то response станет { status: 'forbidden' }, которое нельзя присвоить типу never, в результате чего сборка проваливается, и проблема сразу указывает на забытую ветвь. Использование throw также защищает приложение во время выполнения, если всё равно поступят неожиданные данные. Оборачивание тела ветви default в скобки устраивает инструменты анализа кода, которые фиксируют объявления внутри клеток case.

Используйте тип unknown на границах вашего приложения

any отключает проверку типов. unknown оставляет её включенной и заставляет вас доказать, что представляет собой значение, прежде чем использовать его. Именно такое поведение необходимо там, где поступают ненадежные данные: ответы API, результаты JSON.parse, данные webhook.

Здесь вспомогательная функция fetch намеренно возвращает Promise<unknown>. Прямое обращение к .name приводит к ошибке компиляции, и это становится допустимым только внутри типового защитника:

async function fetchUser(id: string): Promise<unknown> {
  const res = await fetch(`/api/user/${id}`);
  return res.json();
}

const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
  raw.name; // fine, narrowed to User inside the guard
}

isUser — это пользовательский типовой защитник с сигнатурой function isUser(x: unknown): x is User; его тело должно действительно проверять структуру данных во время выполнения. При использовании типа unknown компилятор запрещает доступ к свойствам до тех пор, пока данные не будут проверены. С типом any верификация является необязательной, и часто её пропускают, пока некорректный ответ не попадает в компонент, который предполагал наличие определённого поля. Для более крупных объемов данных библиотеки схем, такие как Zod, могут генерировать как проверки во время выполнения, так и соответствующие типы. Более подробно об этом рассказано в статье шести типобезопасных альтернативах any.

Генерируйте типы из кода вместо того, чтобы хранить копии

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

function createOrderPayload(order: Order) {
  return {
    id: order.id,
    total: order.total,
    lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
  };
}

type OrderPayload = ReturnType<typeof createOrderPayload>;

ReturnType<typeof createOrderPayload> определяет тип возвращаемого значения функции на основе её вывода. При изменении функции тип OrderPayload автоматически обновляется во всех местах его использования. В отличие от этого, интерфейс, создаваемый вручную, со временем устаревает: после нескольких рефакторингов он может указывать на существование поля, которое функция уже давно не возвращает, и компилятор будет полагаться на этот устаревший интерфейс.

Та же идея применима к компонентам из библиотек, которые не принадлежат вам. React.ComponentProps<typeof Button> возвращает именно те атрибуты, которые принимает компонент Button от стороннего разработчика, взятые из его собственных определений типов, поэтому при добавлении нового атрибута в библиотеку не требуется обновлять какие-либо копии. Похожие инструменты, такие как Parameters, Awaited и типы с индексированным доступом (OrderPayload['lineItems'][number]), расширяют этот подход; для обзора см. встроенные в TypeScript утилитарные типы.

Примитивные типы бренда, предотвращающие замену ID

По умолчанию как UserId, так и OrderId являются обычными строками, поэтому передача ID заказа функции, ожидающей ID пользователя, компилируется без проблем. В результате запрос просто возвращает ничего, и кому-то приходится тратить время на выяснение причины.

Брендинг сочетает примитивные элементы с фиктивными свойствами, из-за чего они становятся несовместимыми:

type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId

Свойство __brand никогда не существует во время выполнения; оно существует только в системе типов, что достаточно для того, чтобы TypeScript рассматривал эти два типа как разные. Значения создаются с помощью небольшого конструктора или функции проверки (в примере это выполняется с помощью приведения типа as OrderId), и с этого момента любое случайное смешивание приводит к ошибке компиляции без каких-либо затрат во время выполнения. Держите приведения типов внутри этих нескольких конструкторов, чтобы понятие «бренд» действительно означало «это значение было проверено».

Итоги

Для всего этого не требуется новая фреймворк или библиотека; это тот самый TypeScript, который у вас уже есть, используемый целенаправленно:

  • satisfies проверяет данные, не отбрасывая выведенную структуру; добавьте as const, когда важны литералы.
  • Использование меток делает нерепрезентируемыми невозможные состояния.
  • Проверка типа never превращает забытый случай в ошибку сборки.
  • Значение unknown на границах заставляет выполнять проверку перед использованием.
  • Производные типы остаются синхронизированными с кодом, который генерирует значения.
  • Маркировки отделяют семантически разные строки.
  • Выберите один из этих методов и примените его в своем следующем pull request. Каждый из них переносит определенный класс ошибок с времени выполнения на время компиляции, что и составляет практическую разницу между наличием TypeScript и его реальной защитой кода.