typescript-eslint: правила, которых нет у компилятора
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Компилятор отвечает на один вопрос: сходятся ли типы. Он не спрашивает, зачем вы написали строку, — и потому спокойно пропускает забытый await, присваивание из any и условие, которое никогда не бывает ложным. Эти вопросы задаёт typescript-eslint: линтер, который смотрит на код через тот же вывод типов, но судит не о согласованности, а о смысле.
Линтер — не «более строгий компилятор». У него другой вопрос. Компилятор спрашивает: «можно ли это значение положить в это место». Линтер спрашивает: «а вы точно хотели написать именно так». Разница видна на коде, где типы сошлись безупречно.
Одна диагностика на весь пример — TS2801 про условие, которое всегда истинно. Остальные две строки компилятор принял, и обе напечатали [object Promise]. Формально он прав: строка плюс что угодно даёт строку, а внутрь шаблонного литерала можно подставить почти любое значение. Про то, что await здесь забыт, в системе типов вопроса нет.
Тот же файл под пресетом strictTypeChecked даёт девять замечаний — на четырнадцать строк вместе с пустыми. Вот пять главных — сообщения приведены дословно.
| Что говорит линтер | Правило |
|---|---|
| Promises must be awaited… | no-floating-promises |
Invalid operand for a '+' operation… Got Promise<string> | restrict-plus-operands |
| 'name' will use Object's default stringification format ('[object Object]') when stringified | no-base-to-string |
| Expected non-Promise value in a boolean conditional | no-misused-promises |
| Async function 'loadName' has no 'await' expression | require-await |
Как линтер это узнал? typescript-eslint — две части. Парсер превращает TypeScript в дерево, которое понимает ESLint, а плагин приносит правила. Часть правил смотрит только в дерево — им хватает текста. Остальным нужен ответ на вопрос «какого типа это выражение», и они идут за ним к компилятору: это типизированные правила, ради них линтер поднимает ту же программу, что и tsc.
Сообщения линтера только на английском: переводов, в отличие от tsc, у него нет. Дальше в статье они приводятся как есть — их вы и увидите в редакторе.
Самая дорогая из всех потерь — забытый await перед вызовом, который может отказать. Ошибка при этом не исчезает из программы: она просто перестаёт кому-либо принадлежать.
В выводе одно слово — «отправлено». catch не сработал, хотя ошибка была: try закончился на той же строке, где начался, а отказ пришёл десять миллисекунд спустя. Это то же правило, что и в статье «async/await» из части I: try/catch ловит только то, чего дождались.
Дальше отказ не поймал никто. Его нет ни в выводе примера, ни в window.onerror — отвергнутый промис без обработчика уходит по отдельной дорожке, и заметить его можно только в консоли браузера, где он появится строкой «Uncaught (in promise)». В настоящем приложении это выглядит так: пользователь видит «отправлено», а заказа нет.
Правило no-floating-promises требует, чтобы у каждого промиса был хозяин, и перечисляет способы прямо в тексте ошибки: await, .catch, .then с обработчиком отказа — или явное void, если ждать действительно не нужно.
void перед вызовом — не «вызвать и забыть», а подпись под решением: я вижу здесь промис и отпускаю его осознанно. Линтер такую подпись принимает, а читатель кода видит намерение.
Фоновая отправка метрик, необязательный прогрев кеша — случаи, когда результат никому не интересен. void их закрывает, но лучше него работает .catch с записью в лог: он не только успокаивает линтер, но и оставляет след, если фоновое дело сломается.
Настройка ignoreVoid у правила включена по умолчанию — именно она разрешает форму с void. Выключают её в проектах, где хотят видеть обработчик отказа у каждого промиса без исключений.
Второе правило растёт из того же корня, что и статья «void: почему колбэк может вернуть что угодно»: тип () => void принимает функцию, которая на самом деле что-то возвращает. Возвращённое значение просто игнорируется — и это удобно ровно до тех пор, пока возвращают не промис.
Порядок получился обратный, а «все заказы обработаны» напечаталось первым. forEach не умеет ждать: он вызвал три функции подряд, каждая мгновенно вернула промис, и все три промиса ушли в никуда. Компилятор промолчал, потому что колбэк forEach объявлен как возвращающий void, а void согласен на любое значение.
Линтер на эту строку говорит Promise returned in function argument where a void return was expected. Лечится не колбэком, а циклом: await внутри for..of останавливает сам цикл.
Если порядок неважен и задачи можно вести параллельно, вместо цикла берут Promise.all(ids.map(...)) — map возвращает массив промисов, и он-то как раз никуда не теряется.
Три места, кроме методов массива. Первое — условие: if (checkAccess()) над промисом всегда истинно, здесь линтер и компилятор ругаются хором (TS2801). Второе — обработчик события: button.addEventListener("click", async () => ...) отдаёт промис браузеру, который его не ждёт. Третье — любой чужой API с колбэком () => void.
Рядом живёт зеркальное правило await-thenable: оно ловит await над значением, которое промисом не является. Само по себе это безобидно, но обычно означает, что функцию перепутали или у неё убрали async, а вызов остался.
Про any в учебнике уже сказано главное: это не тип, а отключение проверок. Отсюда следствие, которое трудно заметить в большом файле, — any не остаётся на месте, он растекается по присваиваниям, аргументам и возвратам.
Ни одной диагностики: JSON.parse объявлен как возвращающий any, а any кладётся в User без единого вопроса. Линтер даёт здесь два одинаковых замечания — Unsafe assignment of an `any` value — на обе строки: и там, где any появился, и там, где он притворился пользователем.
Правил про any в пресете recommendedTypeChecked пять, по числу дорог, которыми он расходится.
| Правило | Что ловит |
|---|---|
no-unsafe-assignment | any присвоили переменной или полю |
no-unsafe-member-access | обратились к свойству значения типа any |
no-unsafe-call | вызвали то, что имеет тип any |
no-unsafe-argument | передали any в параметр с настоящим типом |
no-unsafe-return | вернули any из функции, обещавшей тип |
Заметьте: слова any в примере нет ни разу. Поэтому знаменитого no-explicit-any здесь мало — он запрещает писать any руками, а этот any приехал из чужой сигнатуры. Лекарство то же, что в статье про данные извне: обрубить цепочку прямо на границе, объявив разобранное как unknown, и дальше идти через предикат.
JSON.parse — самый частый источник. За ним response.json() из статьи «fetch: запросы к серверу» части I: его тип тоже Promise<any>. Дальше — пакеты без типов и заглушки в .d.ts, где кто-то однажды написал declare const lib: any. И, конечно, as any — единственная форма, которую no-explicit-any действительно ловит.
Полезная привычка: наводить курсор на переменную в редакторе сразу после разбора чужих данных. Если во всплывающей подсказке any — дальше по коду проверок уже нет, сколько бы интерфейсов вы ни написали.
Три правила, которые чаще прочих меняют существующий код. Первое — про || в значении «подставь умолчание».
Все три настройки подменились: ноль, пустая строка и false ложны, и || отбросил их вместе с undefined. Линтер говорит Prefer using nullish coalescing operator (??) instead of a logical or (||), as it is a safer operator — и говорит это только там, где левый операнд по типам может оказаться null или undefined. На string без undefined правило молчит: подставлять там нечего.
Второе правило — про шаблонные строки. restrict-template-expressions запрещает подставлять в ${…} то, у чего нет осмысленного строкового вида, а no-base-to-string прямо называет будущий результат.
Первая строка получает сразу два замечания: Invalid type "Point" of template literal expression и 'point' will use Object's default stringification format ('[object Object]'). Вторая — только первое: массив в строку превращается хоть и коряво, но предсказуемо.
В пресете strictTypeChecked это правило запрещает в шаблоне даже число — самая спорная его настройка. Обычное послабление: allowNumber: true.
Третье правило — no-unnecessary-condition: оно ищет проверки, которые по типам не могут провалиться. И вот здесь важно понимать, чему именно линтер верит.
Unnecessary optional chain on a non-nullish value — по типам retries обязателен, значит ?. лишний. По жизни поля нет вовсе, и вторая строка падает. Правило не ошиблось: оно верно пересказало то, что вы написали в типах. Ошибка глубже — в функции loadConfig, которая пообещала Config, ничего не проверив; на неё, кстати, сработало no-unsafe-return из прошлого раздела.
Отсюда практический вывод: «лишняя» проверка почти всегда означает одно из двух — либо она правда лишняя и осталась от прежней версии типа, либо тип наврал на границе. Чинить надо не проверку, а то, что из этих двух верно.
Размеченное объединение из трёх вариантов и switch с двумя ветками. Функция возвращает void, поэтому компилятору нечего требовать: пути, не возвращающие значение, законны.
Правило switch-exhaustiveness-check скажет Switch is not exhaustive. Cases not matched: "rect". Оно не входит ни в один пресет — включается вручную. Приём с never из статьи про полный разбор вариантов делает то же самое силами компилятора; правило удобно тем, что работает и в функциях без возвращаемого значения, где приёму с never негде развернуться.
Конфиг ESLint сегодня — один файл eslint.config.js (или .mjs) в корне проекта, и внутри него обычный массив объектов: где применять, чем разбирать, какие правила. Пакет typescript-eslint даёт готовые наборы и функцию tseslint.config(), которая нужна только ради удобной типизации и раскрытия пресетов.
Ключевая строка — projectService: true. Это разрешение линтеру поднять полноценную программу TypeScript по вашему tsconfig.json: без неё типизированные правила просто не запустятся и молча промолчат обо всём, что показано выше. В старых конфигах на её месте стоит project: "./tsconfig.json"; projectService пришёл ему на смену и сам разбирается, какому файлу какой конфиг подходит.
Наборов правил четыре, и включаются они друг поверх друга.
| Пресет | Что приносит | Нужны типы |
|---|---|---|
recommended | no-explicit-any, no-unused-vars, ban-ts-comment — то, что видно в тексте | нет |
recommendedTypeChecked | no-floating-promises, no-misused-promises, пять no-unsafe-*, restrict-template-expressions, require-await | да |
strictTypeChecked | сверх прежнего: no-unnecessary-condition, no-non-null-assertion, no-deprecated и более жёсткие настройки старых правил | да |
stylisticTypeChecked | prefer-nullish-coalescing, prefer-optional-chain, prefer-find — про стиль, не про ошибки | да |
Цена у типизированных правил настоящая: чтобы ответить на вопрос о типе выражения, линтеру нужна вся программа целиком, и проход по большому проекту занимает не секунды, а минуты. Обычный набор мер — eslint --cache локально, полный проход в CI и отдельный блок с disableTypeChecked для файлов вне tsconfig.json: конфигов сборки и остатков JavaScript.
Ещё одна причина завести линтер была названа в статье про строгость сверх strict. Флаги noUnusedLocals и noUnusedParameters роняют сборку из-за закомментированной на минуту строки; правило no-unused-vars делает ту же работу предупреждением и умеет прощать имена по образцу — argsIgnorePattern: "^_" разрешает параметр _event, который нужен только чтобы добраться до следующего.
Отдельно стоит ban-ts-comment: @ts-ignore оно запрещает совсем, а @ts-expect-error разрешает только с пояснением — по умолчанию не короче трёх символов, а в strictTypeChecked не короче десяти. Ровно та дисциплина, о которой шла речь в статье про as: подавлять диагностику можно, но молча — нельзя.
Точечное отключение пишется с причиной после двух дефисов: // eslint-disable-next-line @typescript-eslint/no-floating-promises -- метрики шлём фоном. Через полгода это единственное, что объяснит строку.
- Компилятор проверяет согласованность типов, линтер — осмысленность кода; забытый await, лишняя проверка и растёкшийся any для tsc законны.
- no-floating-promises требует у каждого промиса хозяина: await, .catch, .then с обработчиком отказа или явное void.
- no-misused-promises ловит async там, где ждут () => void: колбэк forEach, обработчик события, условие в if.
- Пять правил no-unsafe-* следят за any, который приехал из чужой сигнатуры, — no-explicit-any такое не видит.
- Типизированные правила спрашивают типы у компилятора: им нужен projectService, и потому они заметно медленнее обычных.
- Пресеты складываются: recommended, поверх него recommendedTypeChecked, дальше strictTypeChecked и отдельно stylisticTypeChecked.
1/4В async-функции забыли await: const name = loadName();, а следующей строкой — console.log("Привет, " + name). Что скажет компилятор про эту строку?
2/4Почему ids.forEach(async (id) => { … }) не вызывает ни одной диагностики компилятора?
3/4Строки const raw = JSON.parse(s); и const user: User = raw;. Сколько замечаний даст no-unsafe-assignment и почему молчит компилятор?
4/4Что нужно правилу no-floating-promises, чего не нужно правилу no-explicit-any?
Сумма, которая всегда ноль
Функция должна сложить цены трёх заказов и вернуть 600, а возвращает 0. Компилятор не возражает ни на одну строку. Почините, не меняя priceOf.
Промисы из колбэка forEach никто не забрал: total вернула sum раньше, чем к ней хоть что-то прибавили. Линтер сказал бы Promise returned in function argument where a void return was expected. Колбэк меняем на цикл — await внутри for..of останавливает сам цикл.
Цены не зависят друг от друга, поэтому их можно запросить разом. Тогда промисы собирает Promise.all, и теряться нечему:
Граница, которая врёт
parseUser обещает User, но ничего не проверяет: any из JSON.parse проходит сквозь неё и падает у вызывающего. Перепишите так, чтобы неполные данные останавливались на границе понятной ошибкой, а any дальше parseUser не уходил.
Два шага. Первый — аннотация unknown сразу после разбора: она обрывает цепочку any в единственном месте, где та начинается. Второй — предикат из главы про сужение, который отвечает за обещание, данное в типе возврата.
Единственное as здесь — внутри предиката, на пути от unknown к чтению свойств; наружу оно не торчит. До правки на функции срабатывало no-unsafe-return, после — ни одного замечания.
Заказ [object Object]
Чек печатает Заказ [object Object] принят. Компилятор не против: в шаблонную строку можно подставить почти всё. Сделайте так, чтобы вышло Заказ №7 (Дубай) принят.
Объект в шаблоне превращается в [object Object] — про это линтер говорит сразу двумя правилами: Invalid type "Order" of template literal expression и 'order' will use Object's default stringification format. Подставлять надо поля, а не объект целиком.
Одна тонкость: под strictTypeChecked число в шаблоне тоже запрещено, и на ${order.id} правило сработает снова. Это его самая обсуждаемая настройка; в большинстве проектов ей ставят allowNumber: true.