tsconfig.json: что генерирует tsc --init сегодня

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

Здесь будет встроен видео-разбор темы (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 — ровно та ситуация, когда код опережает конфиг.

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

Компилятор пишет TS2550 и прямо называет виновную настройку: «Вы хотите изменить целевую библиотеку? Попробуйте изменить параметр компилятора lib на es2024 или более поздней версии». А браузер спокойно печатает двойку — Object.groupBy в нём давно есть.

Это и есть суть lib: список обещаний о среде, а не полифилл. Компилятор ничего не добавляет в выпущенный код — он только сверяет ваш код с описанием. Поставите lib выше, чем умеет реальный браузер, — получите молчание на сборке и TypeError у пользователя. Поставите ниже — получите ложные ошибки на рабочем коде.

В сгенерированном конфиге стоит "target": "esnext", то есть «среда свежая, ничего не переписывай». Вместе с ним по умолчанию подключается и разметка браузера: document и window видны без единой строки в конфиге. Именно поэтому для серверного проекта в файле закомментирован набор "lib": ["esnext"] — он убирает DOM, которого на сервере нет.

Вторая работа target — эмит. Здесь одна строка конфига меняет не типы, а поведение программы.

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

Конструктор записал сотню, потомок не написал ни строки кода — и значение пропало. Компилятор предупреждал дважды: TS2564 про поле без инициализатора (та самая ошибка из главы про классы) и TS2612 — «Свойство price перезапишет базовое свойство в Product. Если это сделано намеренно, добавьте инициализатор. В противном случае добавьте модификатор declare или удалите избыточное объявление».

Виноват target. При target от ES2022 и выше сам собой включается useDefineForClassFields, и объявление поля превращается в настоящее поле класса: движок определяет свойство заново, затирая всё, что успел положить конструктор родителя. С "target": "es2015" из старых руководств тот же исходник печатает 100. Лечится словом declare перед полем — оно говорит «это только объявление типа, кода не выпускай».

Вот выпущенный код при target ES2022 и выше — обычный JavaScript, запустите его как есть.

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

А вот он же при старом target: объявления полей просто исчезали, и стирать было нечему.

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

Строка выглядит бессмысленной: пустой список типов в проекте про типы. На самом деле 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 — не модуль, а скрипт: его верхние имена лежат в той же общей области, что и всё остальное. Песочница учебника выполняет примеры именно так, поэтому эффект видно прямо здесь.

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

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["**/*"] — всё дерево от папки с конфигом
excludenode_modules, bower_components, jspm_packages и outDir, если он задан
filesпусто; список пишут только для очень маленьких сборок
rootDirобщая папка всех входных файлов — вычисляется сама
outDirнет: результат ложится рядом с исходником

Блок Other Outputs — про то, что кроме .js. sourceMap нужен отладчику: без карты вы ставите точку останова в выпущенном коде, а не в своём. declaration выпускает .d.ts — типы для тех, кто подключит ваш пакет; declarationMap делает так, что «перейти к определению» ведёт из чужого проекта в ваш исходник, а не в сухое объявление.

Если сборкой занимается бандлер, а tsc нужен только как проверяльщик, все три строки можно выкинуть и поставить "noEmit": true. Тогда компилятор ничего не пишет на диск и просто говорит «да» или «нет».

И самое неочевидное про выпуск: ошибки типов по умолчанию его не отменяют. Компилятор ругается и всё равно создаёт .js.

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

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 или отсутствие stricttsc --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»:

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

Без первой компилятор разрешит document.querySelector в серверном коде, без второй не найдёт import { readFileSync } from "node:fs".

Вернуть исчезнувшую цену

Код печатает undefined, хотя конструктор получил сотню. Почините его, не трогая базовый класс и не добавляя присваиваний в наследника — компилятор в двух своих сообщениях уже сказал, что делать.

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

Объявление поля в наследнике при target от ES2022 выпускается в код и определяет свойство заново — поверх того, что записал конструктор родителя. Модификатор declare говорит «это только про типы, кода не выпускай»:

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

Диагностик после этого не остаётся ни одной: исчезает и TS2612 про перезапись, и TS2564 про поле без инициализатора.

Второе честное решение — удалить объявление совсем. Наследник и так получает price от родителя, повторять тип незачем; дублирующая строка здесь вообще ничего не добавляла, кроме ошибки.

Сгруппировать без Object.groupBy

Поднять lib до es2024 не всегда возможно: в чужом проекте настройку могли выбрать под конкретную версию Node. Соберите тот же результат средствами, которые есть в ES2023, — так, чтобы компилятор молчал.

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

Группировка руками — обычный цикл с накопителем. Тип накопителя описывает Record: ключи заранее неизвестны, значения — списки товаров.

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

Обратите внимание на ?? []: в песочнице учебника noUncheckedIndexedAccess выключен, и компилятор считает byShop[good.shop] готовым массивом. Проверка здесь нужна не ему, а вам — на первой итерации ключа в объекте ещё нет. Со включённым флагом из свежего конфига компилятор потребовал бы её сам.