← Назад к новостям
Точно ли здесь нужен `any`? 13 сценариев из TypeScript-код-ревью — от `unknown` до границы приложения
📰 Habr
📅 06.07.2026 08:44
⏱️ 23 мин
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 — отдельные усилители строгости: в зрелом проекте они могут подсветить много мест сразу, поэтому включать их стоит осознанно. Короткая ментальная модель Перед тем как «заглушить» тип, я задаю три вопроса. Я знаю форму данных и могу её доказать компилятору — или просто утверждаю? Могу доказать — сужаю (проверкой или guard’ом). Утверждаю без доказательства — переношу проверку туда, где данные появляются. Откуда данные? Если извне (сеть, JSON.parse , localStorage , postMessage , catch ) — их настоящий тип unknown , и проверить форму нужно на границе. Я гашу ошибку, потому что значение реально безопасно, — или потому что не хочу разбираться? Первое иногда оправдано и сопровождается комментарием. Второе — технический долг с отложенным сроком. Три вопроса перед 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, а мы вернули any renderProfile(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 под запретом? Нет — у него есть узкие оправданные ниши: локальный адаптер к нетипизированному 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, а проверяет только наличие id function 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 и data interface 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 принимает любое значение. // ❌ считаем, что поймали Error try { 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 и ранний выход. Если можете доказать тип — докажите. Если не можете — не утверждайте. 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 ; (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 ; palette.primary.toUpperCase(); // primary остался string; забытый или лишний токен — ошибка компиляции Ключи здесь — конечный union ColorToken , поэтому satisfies проверяет ещё и полноту: забытый или лишний токен становится ошибкой компиляции. С открытым Record такой проверки ключей не было бы — разрешён любой строковый ключ. 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 фиксирует литералы и делает структуру readonly const STATUSES = ["idle", "loading", "done"] as const; type Status = (typeof STATUSES)[number]; // "idle" | "loading" | "done" const LABELS: Record = { 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 ; после проверки внутри приложения появляется определённый тип. 12. Данные извне — это unknown Главная ловушка: res.json() и JSON.parse статически возвращают any , поэтому данные с границы втекают внутрь без единой проверки. // ❌ any с самого входа const user = await res.json(); // тип any render(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 без основания: ключи объекта не контролируем, но утверждаем узкий union const roles: Record = loadRoles(); const keys = Object.keys(roles) as Array ; // тип обещает только 'admin' | 'user', но в рантайме там окажется и 'guest' // ✅ as, оправданный контрактом языка const FEATURES = { search: true, darkMode: false }; // Object.keys намеренно возвращает string[] (в рантайме у объекта бывают лишние ключи), // но объект локальный, не экспортируется и не дополняется — набор ключей замкнут: const keys = Object.keys(FEATURES) as Array ; Где утверждение уместно: 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 и !. Сначала определите причину ошибки типа, потом выбирайте инструмент: 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 ». Мы договариваемся сначала называть, что именно отключаем и почему. Если назвать причину не получается, значит, есть модель проще и яснее. Репозиторий и источники Playground с 13 TypeScript-примерами, обёрнутыми в React-интерфейс . TypeScript Handbook — Narrowing . TypeScript Handbook — Everyday Types ( unknown , any ) . satisfies (TypeScript 4.9) . useUnknownInCatchVariables . noUncheckedIndexedAccess . Что дальше Это часть цикла — дальше в работе детальные разборы: граница 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-проверкой? Если есть случай, который не влезает в короткое правило, — принесите его в комментарии, разберём вместе.
#Java
#Api
#Бот
#Ai
#Arch
#Web
#Erp
#Javascript
#Логи
#Чат
📊 Источник:
Habr
|
Оригинал