Точно ли здесь нужен `any`? 13 сценариев из TypeScript-код-ревью — от `unknown` до границы приложения

от автора

any, as и ! выключают проверку типов; unknown и проверка на границе заставляют доказать форму данных.

any, as и ! выключают проверку типов; unknown и проверка на границе заставляют доказать форму данных.

any, as и ! не выполняют обещание типа. Сначала спросите: я знаю форму данных или просто утверждаю?

Это вторая статья цикла о TypeScript и React глазами code review. Предыдущая — «Нужен ли здесь useEffect? 12 сценариев из React-код-ревью — от производного состояния до React 19.2».

Красная волнистая линия под строкой раздражает, и самый быстрый способ её убрать — дописать any, as или !. Компилятор замолкает, сборка зеленеет, PR уходит дальше. Вот только ошибка никуда не делась: она переехала из редактора в рантайм, поближе к пользователю.

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

Поэтому на code review я задаю не привычное «как затипизировать, чтобы TypeScript замолчал», а обратный вопрос:

Что именно я здесь отключаю — и правда ли без этого нельзя?

Веду frontend-команду, много времени провожу в чужих диффах, и про any, as и ! у нас постепенно сложился небольшой свод договорённостей для review. Из него и выросла эта статья: 13 сценариев, которые складываются в один короткий фильтр. Для каждого есть пара «плохо → хорошо» и рабочий пример на TypeScript 6 — всё можно потрогать в playground (он написан на React, но сами приёмы относятся к TypeScript в целом). Разберём any и unknown, сужение и type guards, satisfies, as const, оператор ! и валидацию данных на границе.

Главная мысль:

Типы — это проверяемые обещания о данных. any, а также необоснованные as T и !, позволяют компилятору принять такое обещание без доказательства. Если тип неизвестен — это unknown и сужение; если известен, но невиден компилятору — это guard, валидация или контролируемый инвариант, а не слепое утверждение.

Статья будет полезна, если вы:

  • пишете на TypeScript в strict-режиме;

  • регулярно видите any, as и ! на review;

  • хотите договориться в команде, когда утверждение типа оправдано;

  • проектируете слой данных между API и приложением.

Материал опирается на TypeScript Handbook (разделы Everyday Types, Narrowing, а также unknown и оператор satisfies). Здесь я дополняю официальную модель наблюдениями из code review, командным чек-листом, современными флагами TypeScript 6 и проверяемым playground.

Небольшой контекст по версиям: в TS 6 strict стал дефолтом для новых конфигураций, а useUnknownInCatchVariables входит в семейство strict ещё с TS 4.4. TypeScript 7 уже близко и в первую очередь интересен новой Go-реализацией компилятора, но ментальная модель any / unknown / as от этого не меняется.

Строгий tsconfig, на котором собран и сам playground:

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

strict — база, useUnknownInCatchVariables включается вместе с ним. noUncheckedIndexedAccess (добавляет undefined при доступе по индексу) и exactOptionalPropertyTypes — отдельные усилители строгости: в зрелом проекте они могут подсветить много мест сразу, поэтому включать их стоит осознанно.

Короткая ментальная модель

Перед тем как «заглушить» тип, я задаю три вопроса.

  1. Я знаю форму данных и могу её доказать компилятору — или просто утверждаю? Могу доказать — сужаю (проверкой или guard’ом). Утверждаю без доказательства — переношу проверку туда, где данные появляются.

  2. Откуда данные? Если извне (сеть, JSON.parse, localStorage, postMessage, catch) — их настоящий тип unknown, и проверить форму нужно на границе.

  3. Я гашу ошибку, потому что значение реально безопасно, — или потому что не хочу разбираться? Первое иногда оправдано и сопровождается комментарием. Второе — технический долг с отложенным сроком.

Три вопроса перед any, as и !: знаю ли форму данных, пришли ли данные извне, доказываю ли тип.

Три вопроса перед any, as и !: знаю ли форму данных, пришли ли данные извне, доказываю ли тип.

Перед any, as и ! полезно сначала назвать причину: неизвестный тип, внешняя граница или недоказанный инвариант.

Все сценарии ниже — варианты ответа на эти три вопроса.

Где TypeScript перестал защищать

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

type UserDto = {  profile?: { fullName: string };  metrics: unknown;};const user = (await res.json()) as UserDto; // форму ответа никто не проверилconst name = user.profile!.fullName;        // profile объявлен опциональным — ! это глушитconst rating: any = user.metrics;           // metrics был unknown, а мы вернули anyrenderProfile(name, rating.score * 10);

Сборка была зелёной, типы «сходились», всё работало — ровно до дня, когда бэкенд начал присылать profile: null для аккаунтов без заполненной анкеты. Свой вклад внесла каждая строка: as UserDto впустил ответ без проверки формы — а profile: null не подходит даже под объявленный profile?: {...}; profile! заглушил единственное предупреждение компилятора про необязательный профиль; и на реальном null обращение к .fullName уронило рендер. Поле metrics бэкенд-тип объявил как unknown, но : any вернул всё назад — rating.score просто ждал своей очереди.

На review мы не обсуждали эти три места по очереди. Сначала классифицировали причины:

  • as UserDto — приняли внешние данные, не проверив форму: profile: null не соответствует даже объявленному типу;

  • profile! — тип уже говорит, что profile необязателен; ! глушит это предупреждение вместо ветвления;

  • metrics: any — поле объявлено как unknown, но : any снова отключает проверки; нужно сузить.

После такого разбора вопрос «как убрать три ошибки типов?» превращается в «почему они появились?». Именно этот навык полезен на уровне команды: не запрещать any, а договориться называть причину и выбирать инструмент под неё.


Часть I. any — это выключенная проверка, а не тип

01. any отключает проверки

any совместим почти с любым типом в обе стороны (единственное исключение — never), поэтому компилятор перестаёт проверять значение целиком.

// ❌ any разрешает любое обращение к полямconst user: any = await res.json();const age = user.profile.age; // компилируется; на другой форме — рантайм-исключение

Ошибка проявится не при компиляции, а у пользователя. any — это не «тип, который я пока не знаю», а выключенная в этом месте проверка.

// ✅ unknown обязывает проверить форму до обращения к полямconst raw: unknown = await res.json();const user = parseUser(raw); // валидатор вернёт User или бросит понятную ошибку

Граница применимости: быстрый прототип, который вы гарантированно выбросите. Как только код начинает жить дольше эксперимента, безопаснее перейти на unknown и сужение. Demo 01.

02. any заразителен

any легко распространяется: обращения к свойствам, вызовы и вычисления над ним снова дают any (а вот, скажем, сравнения или typeof — уже нет).

// ❌ ошибка расползается по цепочкеconst data: any = await res.json();const total = data.profile.age + data.bonus; // bonus не существует → NaN, и это тоже any

Опечатка в имени поля прошла молча, результат — снова any, и проверки отключаются везде, куда попало значение. Даже на корректных данных получаем тихий NaN.

// ✅ конкретный тип не даёт ошибке распространитьсяconst user = parseUser(raw);const total = user.profile.age + user.bonus;//                                    ~~~~~// Ошибка компиляции: свойства 'bonus' нет у типа 'User'.

Здесь any не помогает, а мешает: «заразительность» — его цена, а не фича. Demo 02.

03. unknown вместо any

any и unknown оба принимают любое значение. Но только unknown и означает «тип пока не известен» — any при этом ещё и отключает проверки.

// ❌ any разрешает вызвать что угодноconst value: any = source();value.toUpperCase(); // компилятор доволен, но для числа или null это TypeError
// ✅ unknown принимает любое значение, но не даёт им пользоваться без суженияconst value: unknown = source();// value.toUpperCase();            // Ошибка: 'value' is of type 'unknown'.if (typeof value === "string") {    value.toUpperCase(); // здесь value сужен до string}

Практическое правило: если тянет написать any, почти всегда нужен unknown плюс проверка. Demo 03.

Сравнение any и unknown: any выключает проверку, unknown требует сужения.

Сравнение any и unknown: any выключает проверку, unknown требует сужения.

any разрешает пользоваться значением без доказательств, unknown заставляет сначала сузить тип.

Итог части: any не описывает данные, а выключает их проверку — и делает это не в одной строке, а по всей цепочке, куда попадает значение. На «я не знаю тип» правильный ответ — unknown.

Значит ли это, что any под запретом? Нет — у него есть узкие оправданные ниши: локальный адаптер к нетипизированному legacy-коду (обёрнутый и изолированный); низкоуровневые generic-ограничения вроде (...args: any[]) => any, где unknown[] имеет другую семантику; временный escape hatch — с узкой областью, комментарием и планом удаления. Общее у них одно: any заперт в узкую, изолированную область и не растекается по коду.


Часть II. Сузить, а не утверждать

04. Встроенное сужение

Если значение — объединение, разберите его проверками, а не утверждением.

// ❌ утверждаем один из вариантов и падаем на остальныхfunction format(value: string | number | Date): string {    return (value as string).toUpperCase();}
// ✅ сужение: в каждой ветке тип известен точноfunction format(value: string | number | Date): string {    if (typeof value === "string") return value.toUpperCase();    if (typeof value === "number") return value.toFixed(2);    return value.toISOString();}

Инструменты: typeof для примитивов, instanceof для классов, in для свойств, сравнение с литералом, проверки на null. Внутри каждой ветки тип — правда, а не обещание. Demo 04.

05. Пользовательские type guards

Проверку формы можно вынести в функцию с типом-предикатом value is T.

// ❌ guard, который лжёт: обещает User, а проверяет только наличие idfunction isUser(value: unknown): value is User {    return typeof value === "object" && value !== null && "id" in value;}

Компилятор доверяет предикату, поэтому после такого guard’а он считает значение полноценным User — и any-проблема просто переезжает на шаг дальше.

// ✅ проверка покрывает всё, что обещает сигнатураfunction isUser(value: unknown): value is User {    if (!isRecord(value)) return false;    if (!isNumber(value.id) || !isString(value.name)) return false;    return isRecord(value.profile) && isNumber(value.profile.age);}

Важная оговорка: предикат — это утверждение, которому компилятор верит на слово. Пишите guard только там, где готовы проверить всё, что он заявляет; такую функцию удобно покрыть юнит-тестом. Demo 05.

06. Discriminated unions

Часто as и ! — симптом того, что тип смоделирован слишком свободно.

// ❌ флаг + необязательные поля: компилятор не связывает ok и datainterface Result {    ok: boolean;    data?: User;    error?: string;}const name = result.data!.name; // ! потому что data?: — при ok:false это undefined
// ✅ дискриминант связывает вариант с его даннымиtype Result =    | { status: "success"; data: User }    | { status: "error"; code: number; message: string };if (result.status === "success") {    result.data.name; // доступно только здесь} else {    result.message; // а это — только здесь}

Смоделируйте данные так, чтобы невозможные состояния были невыразимы, — и сужение станет «бесплатным», без утверждений. Demo 06.

07. catch (e: unknown)

Переменная catch получает тип unknown при включённом useUnknownInCatchVariables (входит в strict, доступен с TS 4.4). И это не придирка: в JavaScript throw принимает любое значение.

// ❌ считаем, что поймали Errortry {    risky();} catch (e) {    log((e as Error).message); // для строки .message === undefined}
// ✅ проверяем, ничего не утверждаяfunction getErrorMessage(error: unknown): string {    if (error instanceof Error) return error.message;    if (typeof error === "string") return error;    return "неизвестная ошибка";}try {    risky();} catch (error) {    log(getErrorMessage(error));}

Заведите один такой getErrorMessage(e: unknown) и зовите его во всех catch. Demo 07.

Итог части: настоящая проверка — та, что остаётся в собранном коде, — превращает unknown в конкретный тип. Утверждение же только заявляет тип, ничего не проверяя, и вся разница вылезает на данных, которых вы не ждали.


Часть III. Утверждения, которые лгут: as и !

Слепые утверждения as и ! против доказательств: сужение, type guard, discriminated union и ранний выход.

Слепые утверждения as и ! против доказательств: сужение, type guard, discriminated union и ранний выход.

Если можете доказать тип — докажите. Если не можете — не утверждайте.

08. as ничего не проверяет

Утверждение типа не преобразует данные и не проверяет их в рантайме — оно лишь меняет мнение компилятора.

// ❌ двойное утверждение прожимает несовместимые формыconst user = legacy as unknown as User;user.name.toUpperCase(); // user.name === undefined → крах

as any и as unknown as T — красные флаги: ими компилятору навязывают заведомо несовместимые типы.

// ✅ нужно преобразование — пишем его явной функциейfunction toUser(source: LegacyUser): User {    return { id: 0, name: source.fullName, email: "", profile: { bio: "", age: source.years } };}

Забыли или опечатали поле — ошибка компиляции, а не undefined в проде. Когда as всё же оправдан, разбираем в сценарии 13. Demo 08.

09. satisfies вместо as

Иногда хочется одновременно проверить, что объект соответствует типу, и сохранить точный вывод.

// ❌ as навязывает тип и теряет конкретику: primary становится string | number[]const palette = {    primary: "#6366f1",    danger: [220, 38, 38],} as Record<string, string | number[]>;(palette.primary as string).toUpperCase(); // приходится утверждать снова
// ✅ satisfies проверяет форму, не расширяя выводtype ColorToken = "primary" | "danger";type RGB = [number, number, number];const palette = {    primary: "#6366f1",    danger: [220, 38, 38],} satisfies Record<ColorToken, string | RGB>;palette.primary.toUpperCase(); // primary остался string; забытый или лишний токен — ошибка компиляции

Ключи здесь — конечный union ColorToken, поэтому satisfies проверяет ещё и полноту: забытый или лишний токен становится ошибкой компиляции. С открытым Record<string, …> такой проверки ключей не было бы — разрешён любой строковый ключ. satisfies (с TypeScript 4.9) — то, что обычно и имеют в виду, дотягивая объект as-ом; его места — конфиги, палитры, карты роутов. Demo 09.

10. as const

as const часто путают с as T, хотя это противоположность.

// ❌ без as const литералы теряютсяconst STATUSES = ["idle", "loading", "done"]; // string[]type Status = (typeof STATUSES)[number]; // string — бесполезно
// ✅ as const фиксирует литералы и делает структуру readonlyconst STATUSES = ["idle", "loading", "done"] as const;type Status = (typeof STATUSES)[number]; // "idle" | "loading" | "done"const LABELS: Record<Status, string> = {    idle: "Ожидание",    loading: "Загрузка",    done: "Готово", // пропустите ключ — ошибка компиляции};

as const обычно добавляет точности, а не скрывает проблему: union-типы, исчерпывающие проверки, ключи для satisfies. Поэтому его не стоит опасаться наравне с as T. Demo 10.

11. Non-null оператор !

! вычёркивает null и undefined из типа, но не из рантайма.

// ❌ утверждаем наличие ключаconst name = users.get(id)!.name; // для отсутствующего id → undefined.name → исключение
// ✅ проверяем результатconst user = users.get(id);if (!user) return notFound(id);const name = user.name;

Чем заменить !: явной проверкой и ранним выходом; ?. и ?? для значений по умолчанию; функцией-ассертом, если отсутствие — это баг. А флаг noUncheckedIndexedAccess добавляет undefined к типу arr[i] и map[key], чтобы компилятор сам напоминал про пропуск. Demo 11.

Итог части: as и ! переносят несоответствие из компиляции в рантайм. Они уместны, только когда вы знаете о значении больше компилятора и можете это обосновать, — а не когда лень разбираться.


Часть IV. Граница приложения: где unknown встречает реальные данные

Граница приложения: снаружи unknown, затем parse или schema, внутри проверенный тип.

Граница приложения: снаружи unknown, затем parse или schema, внутри проверенный тип.

На границе данные имеют тип unknown; после проверки внутри приложения появляется определённый тип.

12. Данные извне — это unknown

Главная ловушка: res.json() и JSON.parse статически возвращают any, поэтому данные с границы втекают внутрь без единой проверки.

// ❌ any с самого входаconst user = await res.json(); // тип anyrender(user.profile.age); // падает на первом же несоответствии
// ✅ на границе — unknown, дальше валидация «один раз на входе»const raw: unknown = await res.json();const user = parseUser(raw); // неверную форму отклонит с явной ошибкойrender(user.profile.age); // внутри границы — уже типизированный User

Провалидируйте данные один раз на входе — и дальше по коду работайте с типом без утверждений. Валидатор — обычная функция, поэтому он покрывается тестами (корректное проходит, зловредное отклоняется). Demo 12.

Границ несколько, а принцип один — «снаружи unknown»:

Источник

Почему нельзя доверять форме

Что делаем

res.json()

статически any, форма зависит от бэкенда

unknown + парсер/схема

localStorage

значение мог записать старый код

unknown + разбор и миграция

postMessage

сообщение пришло из другого окна

проверка origin + разбор payload

catch (e)

бросить можно что угодно

getErrorMessage(e: unknown)

Здесь я намеренно написал разбор руками, чтобы показать идею «parse, don’t validate»: на входе не просто проверяем форму, а получаем новое значение с уже конкретным типом. В продакшене ту же роль надёжнее закрывает schema-библиотека — например, Zod. Это отдельная большая тема, возможно и по ней я напишу статью.

13. Где утверждения оправданы

После стольких антипаттернов важно назвать и обратное: as и ! — не зло сами по себе. Бывает, что гарантию держите вы, а компилятор её выразить не может.

// ❌ as без основания: ключи объекта не контролируем, но утверждаем узкий unionconst roles: Record<string, number> = loadRoles();const keys = Object.keys(roles) as Array<"admin" | "user">;// тип обещает только 'admin' | 'user', но в рантайме там окажется и 'guest'
// ✅ as, оправданный контрактом языкаconst FEATURES = { search: true, darkMode: false };// Object.keys намеренно возвращает string[] (в рантайме у объекта бывают лишние ключи),// но объект локальный, не экспортируется и не дополняется — набор ключей замкнут:const keys = Object.keys(FEATURES) as Array<keyof typeof FEATURES>;

Где утверждение уместно:

  • Object.keys / Object.entries над локальным объектом с замкнутым набором ключей (не экспортируется и не дополняется извне) — язык намеренно отдаёт string[];

  • as const — сужение к литералам (сценарий 10);

  • фикстуры и моки в тестах, где форма известна и локальна;

  • инвариант, который типами не выразить, — через функцию-ассерт с проверкой в рантайме: function assert(c: unknown, m: string): asserts c { if (!c) throw new Error(m); }

  • DOM и сторонние API — но после instanceof или проверки, а не слепым as.

Формула для review: наличие as — не порок; порок — as без основания. Если рядом нет ответа, почему это безопасно, скорее всего это «затычка». Demo 13.

Итог части: граница приложения — самое естественное место для unknown (хотя он полезен и во внутренних универсальных утилитах). Проверьте форму на входе, и внутри приложения система типов снова помогает.


Сравнение инструментов

Инструмент

Что делает

Когда уместен

Что остаётся разработчику

any

Выключает проверки

legacy-адаптер, низкоуровневые generic-ограничения, локализованный временный escape hatch

Полная ответственность за рантайм

unknown

«Не знаю тип» без права использовать

Данные извне, catch

Сузить перед использованием

Сужение / guard

Проверка, которая есть и в рантайме

Объединения, unknown

Написать проверку

satisfies

Проверяет форму, сохраняя вывод

Конфиги, палитры, роуты

Описать целевой тип

as const

Сужает к литералам, readonly

Константы, union’ы

Ничего — это добавляет точности

as T

Меняет мнение компилятора

Обоснованный инвариант

Доказать и прокомментировать

!

Убирает null/undefined из типа

Доказанное не-null

Гарантировать это или проверить

Не выстраивайте эти инструменты в одну «лестницу»: они решают разные задачи. Вопрос не «что мощнее», а «какова причина».

Чек-лист перед тем, как заглушить тип

Дерево решений: какой TypeScript-инструмент выбрать вместо any, as и !.

Дерево решений: какой TypeScript-инструмент выбрать вместо any, as и !.

Сначала определите причину ошибки типа, потом выбирайте инструмент: unknown, guard, validation, satisfies, as const или явную проверку.

Ситуация

Первый вопрос

Инструмент

Демо

Значение непонятного типа

Точно ли я не знаю тип?

unknown + сужение

03–04

Данные из сети / JSON / storage

Проверял ли я форму?

Валидация на границе

12

Ошибка в catch

e: unknown + guard

07

Компилятор «не видит» известный тип

Могу ли я это доказать?

guard / satisfies

05, 09

Флаг + необязательные поля

Не смоделировать ли явно?

Discriminated union

06

Уверен, что не null

Гарантирует ли это тип?

Ветвление / ?. / инвариант

11

Литеральный конфиг

satisfies / as const

09, 10

Что это говорит об инженерной зрелости

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

Для тимлида следующий шаг — сделать это знание воспроизводимым:

  • на review просить не «убери any», а назвать причину: неизвестный тип, недоказанный инвариант или непроверенная граница;

  • собирать повторяющиеся замечания в короткие правила;

  • закреплять правило примером и тестом — на форму данных или на поведение типов;

  • обсуждать исключения, чтобы правило не превратилось в запрет.

Мы не вводим правило «никаких any и as». Мы договариваемся сначала называть, что именно отключаем и почему. Если назвать причину не получается, значит, есть модель проще и яснее.

Репозиторий и источники

Что дальше

Это часть цикла — дальше в работе детальные разборы:

  • граница server/client и «use client»;

  • TanStack Query: когда серверное состояние больше не нужно хранить в Redux;

  • типизация в React: пропсы, хуки, события, ref.

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

Об авторе

Меня зовут Виктор Горбачёв. Мой Telegram — @team_viktoriko. Senior Frontend-разработчик и Team Lead. Более 7 лет занимаюсь коммерческой разработкой, в основном на React и TypeScript. Моя основная зона интереса — архитектура frontend-приложений: проектирование сервисов с нуля, развитие крупных enterprise-систем, микрофронтенды, сборка проектов на Webpack и Vite, а также постепенное разбиение больших монолитов.

На Хабре пишу о frontend-архитектуре, командной разработке, инженерных процессах и практическом опыте, который обычно остаётся за пределами документации.

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

А где у вас проходит граница между оправданным as, допустимым any и обязательной runtime-проверкой? Если есть случай, который не влезает в короткое правило, — принесите его в комментарии, разберём вместе.

ссылка на оригинал статьи https://habr.com/ru/articles/1055944/