К содержимому журнала

Инженерия

TypeScript без any: прагматичная строгость в большой кодовой базе

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

Переплетённые геометрические фигуры, образующие единую структуру
Содержание

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

any — это заём под большие проценты #

Механика проста и коварна: any выключает проверку типов в выражении, а через него — во всём, что с ним связано. Значение с типом any можно передать куда угодно, прочитать любое поле, вызвать как функцию. Компилятор молчит, тесты на счастье не проверяют, и ошибка всплывает в проде там, где тип мог её поймать за секунду до коммита. Самое неприятное — any заразен: он расползается от границы данных внутрь, и через год непонятно, где ещё доверять типам.

Где any заводится чаще всего — предсказуемо.

  • Ответы внешних API, для которых никто не написал типы;
  • Результат JSON.parse и чтение из localStorage;
  • Колбэки сторонних библиотек с устаревшими декларациями;
  • Быстрые фиксы: «разберёмся с типами потом», — и это потом никогда не наступает;
  • Рефакторинги, где проще отключить проверку, чем обновить типы.

Важно понимать: строгость — это не запрет на неопределённость, а запрет на неучтённую неопределённость. Данные с границы системы действительно непредсказуемы — TypeScript честно говорит об этом типом unknown . Разница принципиальная: any говорит «доверяй всему», unknown говорит «ничему не доверяй, пока не проверишь».

unknown и type guard на границе #

Первый приём — правила входа. Всё, что приходит извне, типизируется как unknown и проходит через проверку. Проверка пишется один раз и живёт в одном месте.

type_guard.tsTypeScript
interface User {
  id: number
  email: string
  roles: string[]
}

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false
  }
  const candidate = value as Record<string, unknown>
  return (
    typeof candidate.id === 'number' &&
    typeof candidate.email === 'string' &&
    Array.isArray(candidate.roles) &&
    candidate.roles.every((role) => typeof role === 'string')
  )
}

export function parseUser(payload: string): User | null {
  const data: unknown = JSON.parse(payload)
  return isUser(data) ? data : null
}

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

Discriminated union вместо флагов #

Второй приём — правила состояний. Типичный антипаттерн: объект с опциональными полями data? , error? и флагом isLoading . Такой тип описывает больше состояний, чем существует в реальности: есть и «данные плюс ошибка», и «загрузка с данными», и обработать их все обязан каждый потребитель. Дискриминируемое объединение описывает только возможные состояния.

fetch_state.tsTypeScript
type FetchState<T> =
  | { phase: 'idle' }
  | { phase: 'loading'; startedAt: number }
  | { phase: 'success'; data: T; fetchedAt: number }
  | { phase: 'error'; message: string; retryable: boolean }

function describe(state: FetchState<User[]>): string {
  switch (state.phase) {
    case 'idle':
      return 'Запрос не начат'
    case 'loading':
      return 'Загружаем…'
    case 'success':
      return 'Получено пользователей: ' + String(state.data.length)
    case 'error':
      return state.retryable ? 'Повторяем попытку…' : 'Ошибка: ' + state.message
  }
}

Компилятор проверяет полноту обработки: добавить ветку состояния без обновления всех switch — ошибка компиляции. Внутри ветки success поле data доступно без проверок, потому что в остальных состояниях его не существует. Это тот редкий случай, когда типы документируют бизнес-логику точнее любых комментариев: невозможно забыть состояние, которое существует в типе.

satisfies: проверка формы без потери вывода #

Третий приём — правила конфигурации. Аннотация типа у объекта сохраняет проверку, но стирает выведение: каждое поле становится шире, чем оно есть. Оператор satisfies проверяет соответствие ограничению, оставляя полям их точные типы.

routes.tsTypeScript
type RouteHandler = (id: number) => string

const routes = {
  home: '/',
  catalog: '/catalog',
  product: (id: number) => '/catalog/' + String(id),
} satisfies Record<string, string | RouteHandler>

// routes.product — по-прежнему функция с точным типом,
// а не union 'string | RouteHandler', как было бы с аннотацией
routes.product(42)

// Опечатка в имени ключа или неверный тип значения ловятся здесь:
// const bad = { cataolg: '/c' } satisfies Record<string, string | RouteHandler>

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

Стоит сказать о цене. Строгие границы требуют писать проверки, которые раньше писались «на всякий случай» в рантайме, просто разбросанно и непоследовательно. Первые две недели скорость падает, потом возвращается — и превышает прежнюю, потому что класс ошибок «undefined is not a function» исчезает целиком. На нашем проекте баги типизации в проде после миграции находятся пальцами одной руки за год, и все — в коде без типов.

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

Был ли материал полезен?

0 полезных · 0 неполезных · 0 оценок

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