tsconfig.json: что генерирует tsc --init сегодня
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
npx tsc --init кладёт в проект сорок четыре строки, и включённых настроек среди них пятнадцать. Руководства пятилетней давности показывают конфиг втрое длиннее и советуют дописать ещё десяток строк — сегодня половина из них стала значением по умолчанию, а вторая половина выводится из соседних. Каждая уцелевшая строка что-то делает с вашим кодом, и это видно на запуске.
Команда создаёт tsconfig.json в текущей папке. Файл с расширением .json, но внутри — комментарии и висячая запятая после последнего поля: компилятор читает его своим разбором формата JSONC, а не JSON.parse. Не пугайтесь, если редактор подсветит это красным, — tsc такой файл понимает.
Настройки разложены по озаглавленным блокам, и заголовки эти — обычные комментарии. Вот весь список включённого, сверху вниз, в том порядке, в каком он лежит в файле.
| Строка | Что она делает |
|---|---|
"module": "nodenext" | как понимать import и export и по каким правилам искать пакеты |
"target": "esnext" | в каком JavaScript выпускать результат и какие библиотеки типов подключить |
"types": [] | какие пакеты @types добавляются в глобальную область без импорта |
"sourceMap": true | карта исходников: в отладчике вы видите свой .ts, а не выпущенный .js |
"declaration": true | рядом с .js кладётся .d.ts — типы для тех, кто подключит ваш код |
"declarationMap": true | карта для .d.ts: «перейти к определению» ведёт в исходник, а не в объявление |
"noUncheckedIndexedAccess": true | обращение по индексу даёт «значение или undefined» |
"exactOptionalPropertyTypes": true | ключа нет и ключ есть со значением undefined — разные объекты |
"strict": true | восемь проверок разом, начиная с запрета неявного any |
"jsx": "react-jsx" | как превращать разметку в .tsx в вызовы функций |
"verbatimModuleSyntax": true | импорты и экспорты выпускаются как написаны, без домысливания |
"isolatedModules": true | каждый файл обязан компилироваться в одиночку |
"noUncheckedSideEffectImports": true | импорт ради побочного эффекта тоже должен куда-то разрешаться |
"moduleDetection": "force" | считать модулем каждый файл, даже без import и export |
"skipLibCheck": true | не проверять типы внутри чужих .d.ts |
Закомментированные строки — не мусор, а подсказки: rootDir с outDir в блоке File Layout, набор для Node в блоке Environment Settings, шесть придирок в Style Options. Их не включили не потому, что они плохи, а потому что выбор зависит от проекта.
Файл начинается со ссылки на aka.ms/tsconfig — она заменила ту самую сотню закомментированных опций из старых версий.
Без аргументов tsc берёт tsconfig.json из текущей папки, а не найдя — поднимается вверх по дереву. Явно путь задаётся флагом -p: tsc -p configs/tsconfig.build.json. Ловушку с tsc app.ts, где конфиг не читается вообще, мы уже видели в статье про запуск.
Полезнее всего команда tsc --showConfig: она печатает конфиг таким, каким его увидел компилятор. На свежесозданном файле в выводе окажутся две строки, которых вы не писали, — "moduleResolution": "nodenext" и "preserveConstEnums": true. Обе выведены из соседних настроек, и об этом ниже.
target делает две работы сразу. Первая: сообщает, какие встроенные объекты считать существующими — из него выводится lib, список файлов с описанием среды. Вторая: определяет, во что переписывать синтаксис при выпуске. Обе видны на запуске, и обе умеют удивить.
Начнём с первой. В песочнице учебника lib объявлен как ES2023 — ровно та ситуация, когда код опережает конфиг.
Компилятор пишет TS2550 и прямо называет виновную настройку: «Вы хотите изменить целевую библиотеку? Попробуйте изменить параметр компилятора lib на es2024 или более поздней версии». А браузер спокойно печатает двойку — Object.groupBy в нём давно есть.
Это и есть суть lib: список обещаний о среде, а не полифилл. Компилятор ничего не добавляет в выпущенный код — он только сверяет ваш код с описанием. Поставите lib выше, чем умеет реальный браузер, — получите молчание на сборке и TypeError у пользователя. Поставите ниже — получите ложные ошибки на рабочем коде.
В сгенерированном конфиге стоит "target": "esnext", то есть «среда свежая, ничего не переписывай». Вместе с ним по умолчанию подключается и разметка браузера: document и window видны без единой строки в конфиге. Именно поэтому для серверного проекта в файле закомментирован набор "lib": ["esnext"] — он убирает DOM, которого на сервере нет.
Вторая работа target — эмит. Здесь одна строка конфига меняет не типы, а поведение программы.
Конструктор записал сотню, потомок не написал ни строки кода — и значение пропало. Компилятор предупреждал дважды: TS2564 про поле без инициализатора (та самая ошибка из главы про классы) и TS2612 — «Свойство price перезапишет базовое свойство в Product. Если это сделано намеренно, добавьте инициализатор. В противном случае добавьте модификатор declare или удалите избыточное объявление».
Виноват target. При target от ES2022 и выше сам собой включается useDefineForClassFields, и объявление поля превращается в настоящее поле класса: движок определяет свойство заново, затирая всё, что успел положить конструктор родителя. С "target": "es2015" из старых руководств тот же исходник печатает 100. Лечится словом declare перед полем — оно говорит «это только объявление типа, кода не выпускай».
Вот выпущенный код при target ES2022 и выше — обычный JavaScript, запустите его как есть.
А вот он же при старом target: объявления полей просто исчезали, и стирать было нечему.
Строка выглядит бессмысленной: пустой список типов в проекте про типы. На самом деле types не имеет отношения к вашим аннотациям — он про пакеты, которые добавляют объявления в глобальную область каждого файла, ничего не импортируя.
Раньше это работало так: любая папка внутри node_modules/@types подхватывалась автоматически. Поставили @types/jest ради тестов — и describe с it стали видны в файлах приложения, где им нечего делать. Пришёл @types/node транзитивной зависимостью — и process с Buffer оказались доступны в браузерном коде, который до process не дотянется никогда.
В TypeScript 6 автоматический подхват выключили совсем. Пустой массив в сгенерированном конфиге теперь не выключатель, а надпись на видном месте: вот сюда пишутся глобальные пакеты. Старое поведение вернуть можно — звёздочкой.
| Значение | Что видно без импорта |
|---|---|
| строки нет вообще | ничего: в TypeScript 6 это то же самое, что пустой список |
"types": [] | ничего — и это написано явно, чтобы не гадать |
"types": ["node"] | глобали Node и объявления модулей вроде node:fs |
"types": ["*"] | всё, что лежит в node_modules/@types, — прежнее поведение |
Практическое следствие видно сразу, как только вы напишете серверный код. С "types": [] строка import { readFileSync } from "node:fs" не заработает, даже если пакет @types/node стоит: компилятор ответит TS2591 — «Не удается найти имя node:fs… добавьте node в поле типов в файле tsconfig». Именно этот набор и лежит в конфиге закомментированным под заголовком «For nodejs».
Компилятор в таких сообщениях подсказывает и имя пакета, и строку конфига. Читать их стоит до конца — половина ответа уже там.
Логично было бы ожидать, что явный import подтянет типы независимо от types. С обычными пакетами так и происходит: их типы находятся по тем же правилам, что и код. Но @types/node устроен иначе — он не объявляет пакет с именем node:fs, а пишет амбиентные объявления вида declare module "fs". Такие объявления живут в глобальной области, и в программу они попадают только через types.
Отсюда правило: свои зависимости подключайте импортом, а в types держите короткий осознанный список — обычно это node и, в отдельном конфиге для тестов, ваш тестовый раннер.
Три строки конфига — isolatedModules, verbatimModuleSyntax и moduleDetection — про одно и то же: файл должен переживать компиляцию в одиночку. Причина внешняя. В настоящем проекте .ts в .js превращает чаще не tsc, а esbuild, swc, Vite или сам Node, снимающий типы на лету. Все они смотрят ровно на один файл и понятия не имеют, что лежит в соседних.
isolatedModules — это обещание компилятору: «не пиши код, который нельзя понять по одному файлу». Запрещённого немного, зато оно неочевидно. Реэкспорт типа обязан быть помечен как export type, иначе однофайловый компилятор оставит в выпущенном коде импорт несуществующего значения. А const enum перестаёт подставляться числом в место использования — вместо этого выпускается обычный объект-перечисление, потому что подставить значение, не заглянув в другой файл, невозможно.
С moduleDetection история другая, и она про область видимости. Файл без единого import и export — не модуль, а скрипт: его верхние имена лежат в той же общей области, что и всё остальное. Песочница учебника выполняет примеры именно так, поэтому эффект видно прямо здесь.
TS2451: «Невозможно повторно объявить переменную Map с областью видимости „Блок“». Никакого второго Map в файле нет — он пришёл из библиотеки типов, и в скрипте это тот же самый уровень имён. В проекте так же столкнутся два файла, в каждом из которых просто написано const total = 1.
Настройка "moduleDetection": "force" объявляет модулем каждый файл, и проблема исчезает вместе с общей областью. Тонкость: при "module": "nodenext" этот режим и так включается сам — строка в конфиге страхует вас на случай, если module когда-нибудь поменяют на esnext, где по умолчанию действует определение по содержимому.
Раньше файл-скрипт превращали в модуль строкой export {} — с moduleDetection: force этот приём больше не нужен.
Четвёртая строка того же семейства, verbatimModuleSyntax, требует писать import type там, где импортируется только тип. Разговор о ней — в отдельной статье этой главы, вместе с moduleResolution.
Блок File Layout закомментирован целиком, и это осмысленный выбор: пока outDir не задан, каждый .js ложится рядом со своим .ts. Для пробы удобно, для проекта — нет, поэтому строки и оставлены на виду.
Списка файлов в сгенерированном конфиге тоже нет. Значения по умолчанию таковы.
| Настройка | Если не написана |
|---|---|
include | ["**/*"] — всё дерево от папки с конфигом |
exclude | node_modules, bower_components, jspm_packages и outDir, если он задан |
files | пусто; список пишут только для очень маленьких сборок |
rootDir | общая папка всех входных файлов — вычисляется сама |
outDir | нет: результат ложится рядом с исходником |
Блок Other Outputs — про то, что кроме .js. sourceMap нужен отладчику: без карты вы ставите точку останова в выпущенном коде, а не в своём. declaration выпускает .d.ts — типы для тех, кто подключит ваш пакет; declarationMap делает так, что «перейти к определению» ведёт из чужого проекта в ваш исходник, а не в сухое объявление.
Если сборкой занимается бандлер, а tsc нужен только как проверяльщик, все три строки можно выкинуть и поставить "noEmit": true. Тогда компилятор ничего не пишет на диск и просто говорит «да» или «нет».
И самое неочевидное про выпуск: ошибки типов по умолчанию его не отменяют. Компилятор ругается и всё равно создаёт .js.
TS2345 на сборке, TypeError на запуске — и файл всё-таки выпущен. Так ведёт себя tsc с настройками по умолчанию, и так же устроена песочница учебника: диагностика показывается, а код после неё выполняется. Логика тут есть — при переводе большого проекта на TypeScript иначе было бы нечего запускать.
В боевой сборке этот компромисс не нужен. Либо "noEmitOnError": true в конфиге, либо, что чаще, отдельный шаг tsc --noEmit в проверке перед выкладкой — тогда сборка падает, не дожидаясь пользователя.
В монорепозитории общие настройки выносят в отдельный файл, а конфиг пакета наследуется от него через extends. Наследовать можно и от нескольких файлов сразу — тогда они применяются по порядку, и каждый следующий перекрывает предыдущий.
Важная тонкость: относительные пути внутри унаследованного конфига считаются от файла, где они написаны, а не от того, который его наследует. Из-за этого outDir и include в общем базовом конфиге почти всегда оставляют пустыми.
Найдя в сети статью с длинным tsconfig.json, полезно знать, что из неё можно не переносить. Почти всё лишнее — это значения по умолчанию, которые за последние годы наконец совпали с тем, как люди пишут код.
| Строка из старого руководства | Что с ней сегодня |
|---|---|
"target": "es5" | значение по умолчанию — ES2025, а tsc --init пишет esnext; ES5 нужен ради браузеров, которых больше нет |
"moduleResolution": "node" | выводится из module; при nodenext получается nodenext — проверяется через tsc --showConfig |
"esModuleInterop": true | значение по умолчанию |
"allowSyntheticDefaultImports": true | значение по умолчанию |
"forceConsistentCasingInFileNames": true | значение по умолчанию |
"resolveJsonModule": true | включено само при module вида nodenext |
"lib": ["dom", "es6"] | выводится из target; свой список пишут в основном чтобы убрать DOM на сервере |
"strict": false или отсутствие strict | tsc --init включает строгость, и не только её: рядом стоят noUncheckedIndexedAccess и exactOptionalPropertyTypes |
| сотня закомментированных опций | заменена ссылкой на справочник в шапке файла |
Обратная сторона короткого конфига: настроек не стало меньше, они просто перестали быть видимыми. Когда поведение компилятора удивляет, первым делом стоит спросить его самого — tsc --showConfig покажет всё, включая выведенное, а не только написанное вами.
Оставшиеся шесть строк из сгенерированного файла разбираются в этой же главе: strict — в следующей статье, noUncheckedIndexedAccess с exactOptionalPropertyTypes — в статье про строгость сверх strict, verbatimModuleSyntax — в статье про модули.
- Свежий
tsc --initдаёт сорок четыре строки и пятнадцать включённых настроек; всё остальное из старых руководств стало значением по умолчанию или выводится из соседних строк. targetзадаёт иlib— описание среды для проверки, — и форму выпущенного кода; при значении от ES2022 объявления полей класса начинают затирать значения из конструктора родителя."types": []касается не ваших аннотаций, а пакетов, попадающих в глобальную область без импорта; в TypeScript 6 автоматический подхват@typesвыключен, вернуть его можно значением"*".isolatedModules,verbatimModuleSyntaxиmoduleDetection: forceнужны потому, что код компилирует не толькоtsc: esbuild, swc и Node видят по одному файлу за раз.- Ошибки типов по умолчанию не отменяют выпуск
.js; в боевой сборке это чинится флагомnoEmitOnErrorили отдельным шагомtsc --noEmit. - Что именно увидел компилятор, всегда можно спросить у него самого:
tsc --showConfigпечатает конфиг вместе с выведенными значениями.
1/4В свежем tsconfig.json стоит "types": []. Что перестанет работать из-за этой строки в проекте, где установлен @types/node?
2/4Базовый класс записывает this.price в конструкторе. Наследник не пишет никакого кода — только повторяет объявление поля price: number. Что напечатает обращение к price при "target": "esnext"?
3/4Компилятор говорит TS2550 на Object.groupBy и советует поднять lib до es2024, а браузер этот же код выполняет без единой жалобы. Почему так?
4/4Два файла проекта, в каждом верхняя строка const total = 1. При "module": "esnext" и без строки moduleDetection компилятор ругается на повторное объявление. Почему при "module": "nodenext" той же ошибки нет?
Вычеркнуть лишнее
Коллега принёс tsconfig.json из руководства 2020 года и просит проверить его перед началом работы над серверным проектом на Node.
| Строка | Ваш вердикт |
|---|---|
"target": "es5" | ? |
"module": "commonjs" | ? |
"moduleResolution": "node" | ? |
"esModuleInterop": true | ? |
"forceConsistentCasingInFileNames": true | ? |
"strict": true | ? |
"outDir": "./dist" | ? |
Какие строки сегодня не нужны, какие нужно поменять, а какие оставить как есть? И чего в этом конфиге не хватает для серверного проекта?
Лишние — те, что повторяют значения по умолчанию: moduleResolution выводится из module, а esModuleInterop и forceConsistentCasingInFileNames и так включены. Их можно удалить, ничего не изменив.
Менять стоит две строки. "target": "es5" — наследие мёртвых браузеров; для Node ставится esnext или конкретная версия вроде es2023. Вместо "module": "commonjs" сегодня пишут nodenext: он разбирается и с require, и с import по правилам самого Node, а заодно включает moduleDetection: force и подтягивает moduleResolution.
Остаются как есть "strict": true и "outDir": "./dist".
Не хватает главного для серверного проекта — двух строк из блока «For nodejs»:
Без первой компилятор разрешит document.querySelector в серверном коде, без второй не найдёт import { readFileSync } from "node:fs".
Вернуть исчезнувшую цену
Код печатает undefined, хотя конструктор получил сотню. Почините его, не трогая базовый класс и не добавляя присваиваний в наследника — компилятор в двух своих сообщениях уже сказал, что делать.
Объявление поля в наследнике при target от ES2022 выпускается в код и определяет свойство заново — поверх того, что записал конструктор родителя. Модификатор declare говорит «это только про типы, кода не выпускай»:
Диагностик после этого не остаётся ни одной: исчезает и TS2612 про перезапись, и TS2564 про поле без инициализатора.
Второе честное решение — удалить объявление совсем. Наследник и так получает price от родителя, повторять тип незачем; дублирующая строка здесь вообще ничего не добавляла, кроме ошибки.
Сгруппировать без Object.groupBy
Поднять lib до es2024 не всегда возможно: в чужом проекте настройку могли выбрать под конкретную версию Node. Соберите тот же результат средствами, которые есть в ES2023, — так, чтобы компилятор молчал.
Группировка руками — обычный цикл с накопителем. Тип накопителя описывает Record: ключи заранее неизвестны, значения — списки товаров.
Обратите внимание на ?? []: в песочнице учебника noUncheckedIndexedAccess выключен, и компилятор считает byShop[good.shop] готовым массивом. Проверка здесь нужна не ему, а вам — на первой итерации ключа в объекте ещё нет. Со включённым флагом из свежего конфига компилятор потребовал бы её сам.