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

Содержание
any — это заём под большие проценты #
Ответы внешних API, для которых никто не написал типы; Результат JSON.parseи чтение из localStorage;Колбэки сторонних библиотек с устаревшими декларациями; Быстрые фиксы: «разберёмся с типами потом», — и это потом никогда не наступает; Рефакторинги, где проще отключить проверку, чем обновить типы.
unknown . Разница принципиальная: any говорит «доверяй всему», unknown говорит «ничему не доверяй, пока не проверишь».
unknown и type guard на границе #
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 . Такой тип описывает больше состояний, чем существует в реальности: есть и «данные плюс ошибка», и «загрузка с данными», и обработать их все обязан каждый потребитель. Дискриминируемое объединение описывает только возможные состояния.
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
}
}success поле data доступно без проверок, потому что в остальных состояниях его не существует. Это тот редкий случай, когда типы документируют бизнес-логику точнее любых комментариев: невозможно забыть состояние, которое существует в типе.
satisfies: проверка формы без потери вывода #
satisfies проверяет соответствие ограничению, оставляя полям их точные типы.
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>
