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 — отдельные усилители строгости: в зрелом проекте они могут подсветить много мест сразу, поэтому включать их стоит осознанно.
Короткая ментальная модель
Перед тем как «заглушить» тип, я задаю три вопроса.
-
Я знаю форму данных и могу её доказать компилятору — или просто утверждаю? Могу доказать — сужаю (проверкой или guard’ом). Утверждаю без доказательства — переношу проверку туда, где данные появляются.
-
Откуда данные? Если извне (сеть,
JSON.parse,localStorage,postMessage,catch) — их настоящий типunknown, и проверить форму нужно на границе. -
Я гашу ошибку, потому что значение реально безопасно, — или потому что не хочу разбираться? Первое иногда оправдано и сопровождается комментарием. Второе — технический долг с отложенным сроком.
Перед 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 под запретом? Нет — у него есть узкие оправданные ниши: локальный адаптер к нетипизированному 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 и !
Если можете доказать тип — докажите. Если не можете — не утверждайте.
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; после проверки внутри приложения появляется определённый тип.
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»:
|
Источник |
Почему нельзя доверять форме |
Что делаем |
|---|---|---|
|
|
статически |
|
|
|
значение мог записать старый код |
|
|
|
сообщение пришло из другого окна |
проверка |
|
|
бросить можно что угодно |
|
Здесь я намеренно написал разбор руками, чтобы показать идею «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 (хотя он полезен и во внутренних универсальных утилитах). Проверьте форму на входе, и внутри приложения система типов снова помогает.
Сравнение инструментов
|
Инструмент |
Что делает |
Когда уместен |
Что остаётся разработчику |
|---|---|---|---|
|
|
Выключает проверки |
legacy-адаптер, низкоуровневые generic-ограничения, локализованный временный escape hatch |
Полная ответственность за рантайм |
|
|
«Не знаю тип» без права использовать |
Данные извне, |
Сузить перед использованием |
|
Сужение / guard |
Проверка, которая есть и в рантайме |
Объединения, |
Написать проверку |
|
|
Проверяет форму, сохраняя вывод |
Конфиги, палитры, роуты |
Описать целевой тип |
|
|
Сужает к литералам, |
Константы, union’ы |
Ничего — это добавляет точности |
|
|
Меняет мнение компилятора |
Обоснованный инвариант |
Доказать и прокомментировать |
|
|
Убирает null/undefined из типа |
Доказанное не-null |
Гарантировать это или проверить |
Не выстраивайте эти инструменты в одну «лестницу»: они решают разные задачи. Вопрос не «что мощнее», а «какова причина».
Чек-лист перед тем, как заглушить тип
Сначала определите причину ошибки типа, потом выбирайте инструмент: unknown, guard, validation, satisfies, as const или явную проверку.
|
Ситуация |
Первый вопрос |
Инструмент |
Демо |
|---|---|---|---|
|
Значение непонятного типа |
Точно ли я не знаю тип? |
|
03–04 |
|
Данные из сети / JSON / storage |
Проверял ли я форму? |
Валидация на границе |
12 |
|
Ошибка в |
— |
|
07 |
|
Компилятор «не видит» известный тип |
Могу ли я это доказать? |
guard / |
05, 09 |
|
Флаг + необязательные поля |
Не смоделировать ли явно? |
Discriminated union |
06 |
|
Уверен, что не |
Гарантирует ли это тип? |
Ветвление / |
11 |
|
Литеральный конфиг |
— |
|
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/