Главная / Статьи / Десять привычек использования TypeScript, которые сохраняют крупные кодовые базы читаемыми и безопасными

Десять привычек использования TypeScript, которые сохраняют крупные кодовые базы читаемыми и безопасными

Узнайте десять практических привычек использования TypeScript: от значимых генериков и сужения типов до полных проверок, атрибута readonly и настроек strict в tsconfig — всё это способствует поддерживаемости постоянно растущих кодовых баз.

2557 слов

Большинство проблем с TypeScript в постоянно растущем кодовом базисе связано не с незнанием того, что такое генерические или условные типы. Они возникают из повседневных решений: чрезмерно сложные абстракции, типы, принимающие слишком много данных, использование ключевого слова as повсюду, двойное определение контрактов, неразборчивые генерические сигнатуры, функции, аргументы которых не имеют смысла в месте вызова, типы, разбросанные по случайным файлам, а также компилятор, настроенный слишком слабо для выявления того, на чем зависит команда. В небольшом проекте такие привычки почти не заметны; однако при участии десятков разработчиков и в течение нескольких лет они приводят к накоплению проблем. В этом руководстве рассматриваются десять конкретных практик, которые делают код на TypeScript легким для чтения и изменения, а также чек-лист, который можно использовать во время ревью кода.

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

1. Рассматривайте генерики как способ выражения связей

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

function getFirst<T>(items: T[]): T | undefined {
  return items[0]
}

Параметр типа T берется из аргумента. Передайте массив пользователей:

const users: User[] = [...]

и компилятор автоматически определит соответствующий результат:

const user = getFirst(users)
// User | undefined

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

const products: Product[] = [...]

что даёт результат с правильным типом:

const product = getFirst(products)
// Product | undefined

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

function getFirst(items: unknown[]): unknown {
  return items[0]
}

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

Один момент, на который стоит обратить внимание: при включённом режиме noUncheckedIndexedAccess (описанном в разделе 10) компилятор сам присваивает тип items[0] значению T | undefined, что соответствует здесь явно указанному типу возвращаемого значения.

2. Избегайте превращения всего в генерики

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

function processData<
  T extends Record<string, unknown>,
  K extends keyof T,
  R extends ...
>(...) {
  // ...
}

Разработчик, который откроет этот файл через шесть месяцев, скорее всего, будет смотреть на это иначе. Каждый дополнительный параметр типа — это то, что читатель должен запоминать. Если функция действительно работает только с пользователями, простая сигнатура передаст гораздо больше информации:

function processUser(user: User) {
  // ...
}

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

3. Ограничивайте значения, а не преобразовывайте их

Утверждение типа — это самый быстрый способ устранить жалобы:

const value = something as string

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

if (typeof something === 'string') {
  console.log(something.toUpperCase())
}

Внутри блока if переменная something считается string, поскольку оператор typeof является конструкцией для узкого определения типа. Для объектов необходимо написать пользовательский типовой защитник. Тип возвращаемого значения value is User сообщает компилятору, что результат true означает, что аргумент можно рассматривать как User.

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  )
}

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

if (isUser(value)) {
  console.log(value.name)
}

Контраст прост: ассертация просит компилятор поверить вам, тогда как оператор узкого применения предоставляет доказательства. Имейте в виду, что «страж типов» честен лишь настолько, насколько честен его код. В примере проверяется наличие поля id и name, но не то, какие типы у них хранятся; поэтому для данных из сети или хранилища вам может понадобиться более строгая проверка или инструмент валидации схемы. Компилятор полностью доверяет выводу этого «стража типов».

4. Используйте флаг never для обнаружения неполных ветвлений

Предположим, что статус моделируется как объединение строковых литералов:

type Status =
  | 'pending'
  | 'approved'
  | 'rejected'

switch, который соотносит каждый статус с определенным меткой, кажется полным:

function getLabel(status: Status) {
  switch (status) {
    case 'pending':
      return 'Pending'
    case 'approved':
      return 'Approved'
    case 'rejected':
      return 'Rejected'
  }
}

Сегодня он действительно полон. Проблемы начинаются, когда объединение расширяется, например, когда к нему добавляется состояние «отменено»:

type Status =
  | 'pending'
  | 'approved'
  | 'rejected'
  | 'cancelled'

Status может использоваться в десятках мест, и вы хотите, чтобы компилятор указал на каждое из них, где он больше не охватывает все случаи. Стандартным способом является вспомогательная функция для проверки полноты охвата, принимающая значение never. В ветке default TypeScript уже исключил все обработанные варианты, поэтому оставшийся тип должен быть never. Если какой-то новый вариант проскочит, его невозможно присвоить типу never, и компиляция сбрасывается.

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${value}`)
}

function getLabel(status: Status) {
  switch (status) {
    case 'pending':
      return 'Pending'
    case 'approved':
      return 'Approved'
    case 'rejected':
      return 'Rejected'
    default:
      return assertNever(status)
  }
}

После добавления значения 'cancelled' вызов assertNever(status) становится причиной ошибки компиляции, пока вы не обработаете этот новый случай. Определение объединения типов становится единственным источником правды, а компилятор формирует список мест, которые необходимо обновить. Кроме того, оператор throw защищает программу во время выполнения, если извне системы типов поступит неожиданное значение.

5. Используйте readonly для указания способа использования данных

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

type User = {
  readonly id: string
  name: string
}

Компилятор отклоняет операции присваивания вроде следующей:

user.id = '123'

Массивы можно защищать аналогичным образом. Параметр типа readonly User[] позволяет функции итерироваться и читать элементы, но не добавлять новые элементы, изменять порядок или сортировать массив на месте:

function processUsers(users: readonly User[]) {
  // ...
}

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

6. Не скрывайте реальные структуры за Record<string, unknown>

Подобные подписи встречаются часто:

function process(data: Record<string, unknown>) {
  // ...
}

Иногда это действительно правильный тип. Если функция действительно принимает произвольные данные в формате «ключ-значение», например универсальный логгер или инструмент сериализации, использование общего типа является честным подходом. Проблема возникает, когда вы уже знаете, что представляет собой объект. Возьмем ту же сигнатуру:

function process(data: Record<string, unknown>) {
  // ...
}

и зададим структуру данных, которую мы на самом деле ожидаем:

type User = {
  id: string
  name: string
}

function process(user: User) {
  // ...
}

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

7. Проектирование API функций, которые сами себя объясняют

Аргументы, указанные по позиции, быстро становятся неочевидными, особенно логические значения. Читая подобный вызов, невозможно понять, что контролируют true и false, не открывая определение:

createUser(
  'Akshat',
  'akshat@example.com',
  true,
  false,
)

Объект options размещает информацию о значениях прямо в месте вызова:

createUser({
  name: 'Akshat',
  email: 'akshat@example.com',
  sendWelcomeEmail: true,
  isAdmin: false,
})

Затем функция объявляет специальный тип для своих параметров:

type CreateUserOptions = {
  name: string
  email: string
  sendWelcomeEmail: boolean
  isAdmin: boolean
}

function createUser(options: CreateUserOptions) {
  // ...
}

Преимущества такого подхода растут с увеличением количества параметров. Два аргумента, указанные по позиции, обычно работают нормально; семь почти всегда приводят к ошибкам, особенно когда несколько из них имеют одинаковый тип и могут быть поменяны друг с другом без последствий. Объект options также упрощает добавление дополнительных полей позже, не нарушая работу существующих вызовов.

8. Размещайте типы рядом с областью, которую они описывают

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

Лучшим вариантом является размещение типов рядом с кодом той области приложения, для которой они предназначены:

users/
  user.types.ts
  user.service.ts
  user.repository.ts

payments/
  payment.types.ts
  payment.service.ts
  payment.repository.ts

facilities/
  facility.types.ts
  facility.service.ts
  facility.repository.ts

Конкретная структура папок имеет меньшее значение, чем принцип, на котором она основана: тип должен находиться рядом с той областью приложения, которую он описывает. Если вы знаете, где находится бизнес-логика обработки платежей, вы также должны быть в состоянии определить местоположение типов платежей. Действительно универсальные типы, такие как общие шаблоны API, могут сохраняться в небольшом общем модуле.

9. Сохраняйте систему типов проще, чем бизнес-логику

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

type Magic<T> =
  T extends infer U
    ? U extends Record<string, unknown>
      ? {
          [K in keyof U as K extends `${string}Id`
            ? K
            : never]: U[K]
        }
      : never
    : never

Программирование на уровне типов имеет законные применения, особенно в библиотеках. Но наступает момент, когда тип добавляет больше сложности, чем устраняет. Если коллеге необходимо расшифровать сложный тип, прежде чем он сможет понять правило бизнес-логики, которое этот тип поддерживает, спросите, не будет ли достаточно более простой версии. Иногда ответ — нет, и такая сложность оправдана; часто же — нет. Умность не является качеством. Простые и читаемые типы обычно превосходят впечатляющие, а когда действительно требуется сложный тип, короткий комментарий и несколько тестов на типы значительно облегчают его поддержку.

10. Целенаправленная настройка tsconfig

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

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

strict включает набор проверок, в том числе strictNullChecks и noImplicitAny. Два других параметра являются отдельными опциями, которые strict не включает автоматически: noUncheckedIndexedAccess добавляет значение undefined к результатам чтения по индексу в массивах и объектах, а exactOptionalPropertyTypes различает свойства, отсутствующие полностью, и те, которые явно установлены в undefined. Оба этих параметра могут выявить множество ошибок в существующем проекте.

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

"strict": true

Режим строгости существует не для того, чтобы сделать TypeScript сложным в использовании. Он заставляет компилятор честно отражать неопределенности, что и является основной причиной использования TypeScript — выявление проблем до того, как с этим столкнутся пользователи.

Почему вместе эти привычки важны

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

type Payment =
  | {
      status: 'SUCCESS'
      transactionId: string
    }
  | {
      status: 'FAILED'
      error: string
    }

Не читая никакой реализации, он узнает бизнес-правило: успешная оплата имеет идентификатор транзакции, а неудачная — сообщение об ошибке. Тип указывает на то, как ведет себя соответствующая часть системы, а не просто на то, что какое-то свойство является строкой. Именно к этому стандарту следует стремиться.

Чек-лист для проверки кода

Перед сохранением кода на TypeScript ответьте на следующие вопросы:

  • Может ли этот any быть unknown или каким-то конкретным типом?
  • Не повторяет ли эта аннотация то, что компилятор уже смог определить?
  • Отражают ли эти типы только допустимые состояния области?
  • Является ли эта свойство необязательной потому, что оно действительно таковым является, или из соображений удобства?
  • Не описал бы союз более точно это состояние?
  • Используется ли здесь as потому, что значение действительно безопасно, или просто для того, чтобы скрыть ошибку?
  • Отражает ли этот генерик реальную связь между типами?
  • Является ли новая абстракция понятнее, чем код, который она заменяет?
  • Может ли читатель понять значение каждого аргумента в месте вызова?
  • Не прояснило бы readonly вопросы принадлежности или неизменяемости?
  • Заметит ли компилятор изменения в этой области?
  • Сможет ли коллега понять этот тип без его декодирования?

Последний вопрос, как правило, имеет наибольшее значение.

Итог: типы как инструмент проектирования

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

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

  • Используйте генерики для описания взаимосвязей, а обычные сигнатуры — когда не нужно фиксировать какие-либо взаимосвязи.
  • Предпочитайте доказательства (узкое ограничение, защитные меры, тщательная проверка) утверждениям.
  • Пусть типы отражают намерения с помощью атрибута readonly, точных форм и объектов с параметрами.
  • Держите типы ближе к их области применения и проще, чем логика, которую они обслуживают.
  • Точно знайте, что проверяет ваш файл tsconfig, и целенаправленно ужесточайте его настройки.