typescript-eslint: правила, которых нет у компилятора

настройка≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Компилятор отвечает на один вопрос: сходятся ли типы. Он не спрашивает, зачем вы написали строку, — и потому спокойно пропускает забытый await, присваивание из any и условие, которое никогда не бывает ложным. Эти вопросы задаёт typescript-eslint: линтер, который смотрит на код через тот же вывод типов, но судит не о согласованности, а о смысле.

Разделы темы:·

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна диагностика на весь пример — 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 stringifiedno-base-to-string
Expected non-Promise value in a boolean conditionalno-misused-promises
Async function 'loadName' has no 'await' expressionrequire-await

Как линтер это узнал? typescript-eslint — две части. Парсер превращает TypeScript в дерево, которое понимает ESLint, а плагин приносит правила. Часть правил смотрит только в дерево — им хватает текста. Остальным нужен ответ на вопрос «какого типа это выражение», и они идут за ним к компилятору: это типизированные правила, ради них линтер поднимает ту же программу, что и tsc.

Сообщения линтера только на английском: переводов, в отличие от tsc, у него нет. Дальше в статье они приводятся как есть — их вы и увидите в редакторе.

Самая дорогая из всех потерь — забытый await перед вызовом, который может отказать. Ошибка при этом не исчезает из программы: она просто перестаёт кому-либо принадлежать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В выводе одно слово — «отправлено». catch не сработал, хотя ошибка была: try закончился на той же строке, где начался, а отказ пришёл десять миллисекунд спустя. Это то же правило, что и в статье «async/await» из части I: try/catch ловит только то, чего дождались.

Дальше отказ не поймал никто. Его нет ни в выводе примера, ни в window.onerror — отвергнутый промис без обработчика уходит по отдельной дорожке, и заметить его можно только в консоли браузера, где он появится строкой «Uncaught (in promise)». В настоящем приложении это выглядит так: пользователь видит «отправлено», а заказа нет.

Правило no-floating-promises требует, чтобы у каждого промиса был хозяин, и перечисляет способы прямо в тексте ошибки: await, .catch, .then с обработчиком отказа — или явное void, если ждать действительно не нужно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

void перед вызовом — не «вызвать и забыть», а подпись под решением: я вижу здесь промис и отпускаю его осознанно. Линтер такую подпись принимает, а читатель кода видит намерение.

Фоновая отправка метрик, необязательный прогрев кеша — случаи, когда результат никому не интересен. void их закрывает, но лучше него работает .catch с записью в лог: он не только успокаивает линтер, но и оставляет след, если фоновое дело сломается.

Настройка ignoreVoid у правила включена по умолчанию — именно она разрешает форму с void. Выключают её в проектах, где хотят видеть обработчик отказа у каждого промиса без исключений.

Второе правило растёт из того же корня, что и статья «void: почему колбэк может вернуть что угодно»: тип () => void принимает функцию, которая на самом деле что-то возвращает. Возвращённое значение просто игнорируется — и это удобно ровно до тех пор, пока возвращают не промис.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок получился обратный, а «все заказы обработаны» напечаталось первым. forEach не умеет ждать: он вызвал три функции подряд, каждая мгновенно вернула промис, и все три промиса ушли в никуда. Компилятор промолчал, потому что колбэк forEach объявлен как возвращающий void, а void согласен на любое значение.

Линтер на эту строку говорит Promise returned in function argument where a void return was expected. Лечится не колбэком, а циклом: await внутри for..of останавливает сам цикл.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Если порядок неважен и задачи можно вести параллельно, вместо цикла берут Promise.all(ids.map(...)) — map возвращает массив промисов, и он-то как раз никуда не теряется.

Три места, кроме методов массива. Первое — условие: if (checkAccess()) над промисом всегда истинно, здесь линтер и компилятор ругаются хором (TS2801). Второе — обработчик события: button.addEventListener("click", async () => ...) отдаёт промис браузеру, который его не ждёт. Третье — любой чужой API с колбэком () => void.

Рядом живёт зеркальное правило await-thenable: оно ловит await над значением, которое промисом не является. Само по себе это безобидно, но обычно означает, что функцию перепутали или у неё убрали async, а вызов остался.

Про any в учебнике уже сказано главное: это не тип, а отключение проверок. Отсюда следствие, которое трудно заметить в большом файле, — any не остаётся на месте, он растекается по присваиваниям, аргументам и возвратам.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики: JSON.parse объявлен как возвращающий any, а any кладётся в User без единого вопроса. Линтер даёт здесь два одинаковых замечания — Unsafe assignment of an `any` value — на обе строки: и там, где any появился, и там, где он притворился пользователем.

Правил про any в пресете recommendedTypeChecked пять, по числу дорог, которыми он расходится.

ПравилоЧто ловит
no-unsafe-assignmentany присвоили переменной или полю
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 — дальше по коду проверок уже нет, сколько бы интерфейсов вы ни написали.

Три правила, которые чаще прочих меняют существующий код. Первое — про || в значении «подставь умолчание».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Все три настройки подменились: ноль, пустая строка и false ложны, и || отбросил их вместе с undefined. Линтер говорит Prefer using nullish coalescing operator (??) instead of a logical or (||), as it is a safer operator — и говорит это только там, где левый операнд по типам может оказаться null или undefined. На string без undefined правило молчит: подставлять там нечего.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второе правило — про шаблонные строки. restrict-template-expressions запрещает подставлять в ${…} то, у чего нет осмысленного строкового вида, а no-base-to-string прямо называет будущий результат.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка получает сразу два замечания: Invalid type "Point" of template literal expression и 'point' will use Object's default stringification format ('[object Object]'). Вторая — только первое: массив в строку превращается хоть и коряво, но предсказуемо.

В пресете strictTypeChecked это правило запрещает в шаблоне даже число — самая спорная его настройка. Обычное послабление: allowNumber: true.

Третье правило — no-unnecessary-condition: оно ищет проверки, которые по типам не могут провалиться. И вот здесь важно понимать, чему именно линтер верит.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Unnecessary optional chain on a non-nullish value — по типам retries обязателен, значит ?. лишний. По жизни поля нет вовсе, и вторая строка падает. Правило не ошиблось: оно верно пересказало то, что вы написали в типах. Ошибка глубже — в функции loadConfig, которая пообещала Config, ничего не проверив; на неё, кстати, сработало no-unsafe-return из прошлого раздела.

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

Размеченное объединение из трёх вариантов и switch с двумя ветками. Функция возвращает void, поэтому компилятору нечего требовать: пути, не возвращающие значение, законны.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило switch-exhaustiveness-check скажет Switch is not exhaustive. Cases not matched: "rect". Оно не входит ни в один пресет — включается вручную. Приём с never из статьи про полный разбор вариантов делает то же самое силами компилятора; правило удобно тем, что работает и в функциях без возвращаемого значения, где приёму с never негде развернуться.

Конфиг ESLint сегодня — один файл eslint.config.js (или .mjs) в корне проекта, и внутри него обычный массив объектов: где применять, чем разбирать, какие правила. Пакет typescript-eslint даёт готовые наборы и функцию tseslint.config(), которая нужна только ради удобной типизации и раскрытия пресетов.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ключевая строка — projectService: true. Это разрешение линтеру поднять полноценную программу TypeScript по вашему tsconfig.json: без неё типизированные правила просто не запустятся и молча промолчат обо всём, что показано выше. В старых конфигах на её месте стоит project: "./tsconfig.json"; projectService пришёл ему на смену и сам разбирается, какому файлу какой конфиг подходит.

Наборов правил четыре, и включаются они друг поверх друга.

ПресетЧто приноситНужны типы
recommendedno-explicit-any, no-unused-vars, ban-ts-comment — то, что видно в текстенет
recommendedTypeCheckedno-floating-promises, no-misused-promises, пять no-unsafe-*, restrict-template-expressions, require-awaitда
strictTypeCheckedсверх прежнего: no-unnecessary-condition, no-non-null-assertion, no-deprecated и более жёсткие настройки старых правилда
stylisticTypeCheckedprefer-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.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Промисы из колбэка forEach никто не забрал: total вернула sum раньше, чем к ней хоть что-то прибавили. Линтер сказал бы Promise returned in function argument where a void return was expected. Колбэк меняем на цикл — await внутри for..of останавливает сам цикл.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Цены не зависят друг от друга, поэтому их можно запросить разом. Тогда промисы собирает Promise.all, и теряться нечему:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Граница, которая врёт

parseUser обещает User, но ничего не проверяет: any из JSON.parse проходит сквозь неё и падает у вызывающего. Перепишите так, чтобы неполные данные останавливались на границе понятной ошибкой, а any дальше parseUser не уходил.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два шага. Первый — аннотация unknown сразу после разбора: она обрывает цепочку any в единственном месте, где та начинается. Второй — предикат из главы про сужение, который отвечает за обещание, данное в типе возврата.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Единственное as здесь — внутри предиката, на пути от unknown к чтению свойств; наружу оно не торчит. До правки на функции срабатывало no-unsafe-return, после — ни одного замечания.

Заказ [object Object]

Чек печатает Заказ [object Object] принят. Компилятор не против: в шаблонную строку можно подставить почти всё. Сделайте так, чтобы вышло Заказ №7 (Дубай) принят.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Объект в шаблоне превращается в [object Object] — про это линтер говорит сразу двумя правилами: Invalid type "Order" of template literal expression и 'order' will use Object's default stringification format. Подставлять надо поля, а не объект целиком.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Одна тонкость: под strictTypeChecked число в шаблоне тоже запрещено, и на ${order.id} правило сработает снова. Это его самая обсуждаемая настройка; в большинстве проектов ей ставят allowNumber: true.