javascript

Чеклист для tsconfig.json

  • вторник, 18 февраля 2025 г. в 00:00:06
https://habr.com/ru/companies/timeweb/articles/877162/



В этой статье я расскажу о настройках TypeScript, определяемых в файле tsconfig.json, которых я использую в своих проектах.


❯ 1. Возможности, не затрагиваемые в этой статье


В этой статье описывается в основном настройка проектов, в которых все локальные модули являются ESM. Мы почти не будем говорить об импорте CommonJS.


Также мы не будем говорить о следующем:



❯ 2. Заметки


Для отображения выводимых типов в исходном коде я использую пакет npm ts-expect, например:


// Проверяем, что выводимым типом `someVariable` является `boolean`
expectType<boolean>(someVariable);

Я часто использую завершающие запятые (trailing commas) в моем JSON, поскольку это поддерживается tsconfig.json и облегчает перестановку, копирование полей и т.д.


❯ 3. Расширение базовых файлов с помощью extends


Эта настройка позволяет нам ссылаться на существующий tsconfig.json с помощью спецификатора модуля (module specifier) (как если бы мы импортировали файл JSON). Этот файл становится основой/базой (base) для расширения (extend) нашим tsconfig.json. Это означает, что наш tsconfig.json содержит все настройки базового, может перезаписывать их и добавлять новые.


Репозиторий GitHub tsconfig/bases содержит все базы, доступные в пространстве имен @tsconfig, которые могут использоваться следующим образом (после локальной установки с помощью npm):


{
  "extends": "@tsconfig/node-lts/tsconfig.json",
}

Ни один из этих файлов мне не подходит. Но они могут послужить хорошей основой для вашего tsconfig.json.


❯ 4. Исходные файлы


{
  "include": ["src/**/*", "test/**/*"],
}

Мы должны сообщить TS, где находятся исходные файлы. Доступные настройки:


  • files — исчерпывающий список (массив) всех исходных файлов
  • include позволяет определять исходные файлы с помощью массива шаблонов с подстановочными знаками (wildcards), которые интерпретируются относительно tsconfig.json
  • exclude позволяет исключать файлы из набора include с помощью массива шаблонов

❯ 5. Готовые файлы


5.1. Директория для записи готовых файлов


"compilerOptions": {
  "rootDir": ".",
  "outDir": "dist",
}

Вот как TS определяет, куда записывать готовые файлы:


  • он берет файл по указанному пути (относительно tsconfig.json)
  • удаляет префикс, определенный с помощью rootDir и
  • помещает результат в outDir

Дефолтным значением rootDir является самый длинный общий префикс относительных путей исходных файлов.


В качестве примера рассмотрим следующий tsconfig.json:


{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist",
  }
}

Структура проекта:


/tmp/my-proj/
  tsconfig.json
  src/
    main.ts
  test/
    test.ts

Результат работы компилятора TS:


/tmp/my-proj/
  dist/
    src/
      main.js
    test/
      test.js

Если мы удалим rootDir из tsconfig.json, результат будет таким же, поскольку дефолтным значением этой настройки является ".".


Однако, результат будет другим, если мы изменим include:


{
  "include": ["src/**/*"],
  "compilerOptions": {
    "outDir": "dist",
  }
}

Теперь дефолтным значением rootDir будет src и результат будет таким:


/tmp/my-proj/
  dist/
    main.js

Поскольку дефолтное значение rootDir зависит от include, я предпочитаю определять rootDir явно.


5.2. Генерация карт исходников


"compilerOptions": {
  "sourceMap": true,
}

sourceMap генерирует карту исходников, связывающую транспилированный JS с оригинальным TS. Это помогает с отладкой и обычно является хорошей идеей.


5.3. Генерация файлов .d.ts (например, для библиотек)


Если мы хотим, чтобы код TS потреблял (consume) наш транспилированный TS, нужно включить генерацию файлов .d.ts:


"compilerOptions": {
  "declaration": true,
  "declarationMap": true, // позволяет импортерам переходить к исходникам
}

Опционально, можно включить исходный код TS в пакет npm и активировать declarationMap. Это позволит импортеру, например, кликнуть по типу и перейти к определению значения, и его редактор отправит ему оригинальный исходный код.


5.3.1. Настройка declarationDir


По умолчанию каждый файл .d.ts размещается рядом с файлом .js. Для того, чтобы это изменить, можно использовать настройку declarationDir.


5.4. Тонкая настройка генерируемых файлов


"compilerOptions": {
  "newLine": "lf",
  "removeComments": false,
}

Приведенные значения являются дефолтными.


  • newLine — определяет символы конца строки для генерируемых файлов. Допустимы следующие значения:
    • lf\n (Unix)
    • crlf\r\n (Windows)
  • removeComments — определяет необходимость удалять комментарии из исходного кода

❯ 6. Возможности языка и платформы


"compilerOptions": {
  "target": "ES2024",
  // Убрать, если предполагается использование DOM
  "lib": [ "ES2024" ],
}

6.1. target


target определяет, какой новый синтаксис JS транспилируется в старый синтаксис. Например, если target имеет значение ES5, то стрелочная функция () => {} будет транспилирована в функциональное выражение function() {}.


  • tsconfig/bases содержит рекомендуемые настройки для разных платформ
  • значение ESNext означает "самую новую версию, поддерживаемую установленным TS". Поскольку эти версии меняются между версиями TS, это может привести к проблемам при обновлении

Интересно, должна ли быть настройка для отключения транспиляции? С другой стороны, возможность писать современный JS для старых браузеров является очень удобной.


6.1.1. Выбор правильной цели


Мы должны выбирать версию ECMAScript, которая работает на всех целевых платформах. Для этого можно воспользоваться одной из следующих таблиц:



Все официальные базы также содержат target.


6.2. lib


lib определяет, какие доступны типы встроенных API, например, Math или методы встроенных типов:


  • документация TS описывает, как значения могут добавляться в массив. Полный их список можно найти в репозитории TS
  • существуют категории, такие как ES2024 и DOM, и подкатегории, такие как DOM.Iterable и ES2024.Promise
  • значения не регистрозависимы: автодополнения VSCode содержат много заглавных букв, названия файлов не содержат. Значения lib могут записываться любым способом

Когда TS поддерживает определенный API? Этот API должен быть "доступен без префиксов/флагов хотя бы в 2 разных браузерных движках (не только в 2 браузерах на основе Chromium)" (источник).


6.2.1. Настройка lib через target


target определяет дефолтное значение lib: если последнее опущено, а значением target является ES2024, тогда значением lib будет ES2024.Full. Однако, сами ES2024.Full мы использовать не можем. Если мы хотим сделать это вручную, то должны перечислить в lib все, что содержится в es2024.full.d.ts:


/// <reference lib="es2024" />
/// <reference lib="dom" />
/// <reference lib="webworker.importscripts" />
/// <reference lib="scripthost" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />

Мы можем наблюдать интересный феномен в этом файле:


  • категория ES20YY обычно включает все ее подкатегории
  • категория DOM не включает, например, DOM.Iterable пока не является ее частью

Среди прочего, DOM.Iterable позволяет перебирать список узлов (NodeList):


for (const $div of document.querySelectorAll('div')) {}

6.3. Типы встроенных API Node.js


Типы для Node.js содержатся в отдельном пакете:


npm i -D @types/node

❯ 7. Модульная система


7.1. module


Следующие настройки определяют, как TS ищет импортируемые модули:


"compilerOptions": {
  "module": "Node16",
  "noUncheckedSideEffectImports": true,
}

7.1.1. module


С помощью этой настройки определяется система обработки модулей. При правильном значении этой настройки можно забыть про настройку moduleResolution, которая получит хорошее дефолтное значение. Документация TS рекомендует устанавливать одно из 2 значений:


  • Node.js: Node16 поддерживает как CommonJS, так и последние возможности ESM
    • moduleResolution устанавливается в значение Node16
    • существует также значение NodeNext, но оно является динамическим. В настоящее время оно эквивалентно Node16, но может измениться в будущем, что может сломать код
  • сборщики: preserve поддерживает как CommonJS, так и последние возможности ESM. Оно совпадает с тем, что делает большинство сборщиков
    • moduleResolution устанавливается в значение bundler

Таким образом, большинство сборщиков подражает Node.js. Я всегда использую Node16 и не сталкивался ни с какими проблемами.


Обратите внимание, что в обоих случаях TS заставляет нас указывать полные названия импортируемых локальных модулей. Мы не может опускать расширения файлов, как было принято во времена, когда Node.js компилировался только в CommonJS. Новый подход отражает то, как работают ESM.


module: 'Node16' устанавливает target: 'es2022', но я предпочитаю устанавливать target вручную, поскольку module и target связаны не так тесно, как module и moduleResolution. Кроме того, module: 'bundler' ничего не устанавливает.


7.1.2. noUncheckedSideEffectImports


По умолчанию, TS не жалуется на импорт несуществующего файла. Это объясняется тем, что некоторые сборщики ассоциируют артефакты, не являющиеся TS, с модулями. Поэтому TS интересуют только файлы TS. Пример импорта не TS:


import './component-styles.css';

Это приводит к тому, что TS не жалуется на импорт несуществующего файла TS/JS. Ошибка возникнет только при попытке что-либо импортировать из такого файла:


import './does-not-exist.js'; // ошибки нет!

Установка noUncheckedSideEffectImports в значение true меняет это. Позже мы поговорим об альтернативном импорте прочих (не TS) артефактов.


7.2. Отключение генерации файлов


В настоящее время большинство небраузерных платформ умеют выполнять код TS напрямую, без необходимости его транспиляции:


"compilerOptions": {
  "allowImportingTsExtensions": true,
  // Требуется только при компиляции в JS
  "rewriteRelativeImportExtensions": true,
}

  • allowImportingTsExtensions — позволяет при импорте ссылаться на TS версию модуля, а не на его транспилированную версию
  • rewriteRelativeImportExtensions — позволяет транспилировать код TS, предназначенный для прямого выполнения. По умолчанию, TS не меняет спецификаторы модулей при импорте. Здесь есть несколько нюансов:
    • перезаписываются только относительные пути
    • они перезаписываются "наивно", без учета настроек baseUrl и paths
    • пути, определяемые через exports и imports, не считаются относительными и не перезаписываются

7.3. Импорт JSON


"compilerOptions": {
  "resolveJsonModule": true,
}

Эта настройка позволяет импортировать файлы JSON как модули:


import config from './config.json' with { type: 'json' };
console.log(config.hello);

7.4. Импорт прочих артефактов


При импорте файла basename.ext с расширением, которое незнакомо TS, он заглядывает в файл basename.d.ts. Если ext там отсутствует, вызывается ошибка. Документация TS содержит хороший пример такого файла.


Существует 2 способа избежать проблем с импортом незнакомых файлов.


Во-первых, можно использовать настройку allowArbitraryExtensions для подавления всех ошибок такого типа.


Во-вторых, можно создать объявление модуля окружения (ambient module declaration) с подстановочным знаком — файл .d.ts. Следующий пример подавляет все ошибки, связанные с импортом файлов с расширением .css:


// ./src/globals.d.ts
declare module "*.css" {}

❯ 8. Проверка типов


"compilerOptions": {
  "strict": true,
  "exactOptionalPropertyTypes": true,
  "noFallthroughCasesInSwitch": true,
  "noImplicitOverride": true,
  "noImplicitReturns": true,
  "noPropertyAccessFromIndexSignature": true,
  "noUncheckedIndexedAccess": true,
}

strict, на мой взгляд, является обязательной настройкой. Что касается остальных настроек, решайте сами, насколько строгим должен быть ваш код. Можно начать с добавления всех настроек, и смотреть, какие из них будут слишком проблематичными, на ваш вкус. В этом разделе мы не будем говорить о настройках, охватываемых strict (таких как noImplicitAny).


  • noFallthroughCasesInSwitch — если true, непустые блоки case в инструкции switch должны заканчиваться break, return или throw
  • noImplicitOverride — если true, методы, перезаписывающие методы суперкласса, должны иметь модификатор override
  • noImplicitReturns — если true, тогда "неявный возврат" (конец функции или метода) разрешается только в случае, когда возвращаемым типом является void

8.1. exactOptionalPropertyTypes


Если true, то в следующем примере .colorTheme может быть опущено, но не установлено в undefined:


interface Settings {
  // Отсутствие свойства означает 'system'
  colorTheme?: 'dark' | 'light';
}
const obj1: Settings = {}; // разрешено
const obj2: Settings = { colorTheme: undefined }; // запрещено

8.2. noPropertyAccessFromIndexSignature


Если true, то для типов, таких как в следующем примере, нельзя использовать точечную нотацию для доступа к неизвестным свойствам:


interface ObjectWithId {
  id: string,
  [key: string]: string;
}
declare const obj: ObjectWithId;

const value1 = obj.id; // разрешено
const value2 = obj['unknownProp']; // разрешено
const value3 = obj.unknownProp; // запрещено

8.3. noUncheckedIndexedAccess


Если true, то типом неизвестного свойства будет объединение (union) undefined и типа сигнатуры индекса:


interface ObjectWithId {
  id: string,
  [key: string]: string;
}
declare const obj: ObjectWithId;
expectType<string>(obj.id);
expectType<undefined | string>(obj.unknownProp);

8.3.1. noUncheckedIndexedAccess и массивы


Настройка noUncheckedIndexedAccess также влияет на обработку массивов:


const arr = ['a', 'b'];
const elem = arr[0];
expectType<undefined | string>(elem);

Если эта настройка имеет значение false, типом элемента массива будет string.


Распространенной практикой является проверка длины массива перед доступом к элементу. Однако, это не работает с noUncheckedIndexedAccess:


function logElemAt0(arr: Array<string>) {
  if (0 < arr.length) {
    const elem = arr[0];
    expectType<undefined | string>(elem);
    console.log(elem);
  }
}

В данном случае следует использовать другой подход:


function logElemAt0(arr: Array<string>) {
  if (0 in arr) {
    const elem = arr[0];
    expectType<string>(elem);
    console.log(elem);
  }
}

С одной стороны, новый подход отражает тот факт, что массивы могут содержать дыры. С другой стороны, начиная с ES6, JS считает дыры элементами со значением undefined:


> Array.from([,,,])
[undefined, undefined, undefined]

8.4. Настройки для проверки типов имеют хорошие значения по умолчанию


По умолчанию, следующие настройки генерируют предупреждения в редакторах, но мы может выбрать генерацию ошибок компиляции или игнорирование соответствующих проблем:


  • allowUnreachableCode
  • allowUnusedLabels
  • noUnusedLocals
  • noUnusedParameters

❯ 9. Совместимость: облегчение внешним инструментам компиляции TS в JS и определений типов


"compilerOptions": {
  "verbatimModuleSyntax": true,
  "isolatedDeclarations": true,
}

Компилятор TS выполняет 3 задачи:


  1. Проверка типов.
  2. Генерация файлов JS.
  3. Генерация файлов определений (declaration files).

В настоящее время внешние инструменты могут выполнять последние две задачи намного быстрее. Для этого требуется следующее:


  • генерация результата не должна требовать поиска информации в файлах, импортируемых исходным файлом
  • также не нужен семантический анализ, только синтаксический

Существует 2 настройки для установки этих ограничений — они вызывают ошибки компиляции, но не влияют на генерацию JS и определений:


  • verbatimModuleSyntax помогает с компиляцией TS в JS
  • isolatedDeclarations помогает с компиляцией TS в определения

9.1. verbatimModuleSyntax


Большую часть "типовых" частей файла TS легко определить. Исключением являются импорт: без (относительно простого) семантического анализа мы не знаем, импортируется тип (TS) или значение (JS).


Если verbatimModuleSyntax включен, мы делаем добавление ключевого слова type к импортам типов обязательным:


// Исходный код
import { type SomeInterface, SomeClass } from './my-module.js';

// Результат
import { SomeClass } from './my-module.js';

Обратите внимание, что класс — это и значение, и тип. В данном случае type указывать не нужно, поскольку эта часть синтаксиса может остаться в JS.


Нам также нужно указывать type при экспорте типов:


interface MyInterface {}
export { type MyInterface };

// Альтернатива
export interface MyInterface {}

9.1.1. isolatedModules


Активация verbatimModuleSyntax также активирует isolatedModules, которая защищает нас от использования некоторых других возможностей, которые могут приводить к проблемам.


Кроме того, эта настройка позволяет esbuild компилировать TS в JS (источник).


9.2. isolatedDeclarations


isolatedDeclarations заставляет нас добавлять аннотации типов к тому, что возвращают экспортируемые функции и методы. Это означает, что внешним инструментам не нужно выводить типы возвращаемых значений самостоятельно.


Информация о релизе TS 5.5 содержит подробный раздел об изолированных определениях.


9.3. noEmit


Иногда мы хотим использовать TS только для проверки типов, например, когда мы запускаем TS вручную или используем внешние инструменты для компиляции файлов TS (в файлы JS, определения и т.д.):


"compilerOptions": {
  "noEmit": true,
}

  • noEmit — если true, tsc будет только проверять типы, он не будет генерировать никакие файлы

Удалять ли настройки генерации файлов зависит от того, используются ли они внешними инструментами.


❯ 10. Импорт CommonJS из ESM


Проблемы импорта модуля CommonJS из модуля ESM:


  • в ESM дефолтный экспорт — это свойство .default объекта пространства имен модуля
  • в CommonJS объект модуля — это дефолтный экспорт, например, существует много модулей CommonJS, которые делают module.exports функцией

Существует 2 настройки, которые могут с этим помочь.


10.1. allowSyntheticDefaultImports


Эта настройка влияет только на проверку типов, она не влияет на генерацию файлов JS: если true, дефолтный импорт модуля CommonJS указывает на module.exports (а не на module.exports.default), но только при отсутствии module.exports.default.


Это имитирует то, как Node.js обрабатывает дефолтные импорты модулей CommonJS (источник): "При импорте модулей CommonJS объект module.exports предоставляется как дефолтный экспорт. Именованные экспорты могут быть доступны как результат статического анализа для обеспечения лучшей совместимости с экосистемой".


Нужна ли нам эта настройка? Да, но она автоматически включается при moduleResolution: 'bundler' или module: 'Node16' (которая активирует esModuleInterop, которая активирует allowSyntheticDefaultImports).


10.2. esModuleInterop


Эта настройка влияет на компиляцию кода CommonJS:


  • если false:
    • import * as m from 'm' компилируется в const m = require('m')
    • import m from 'm' компилируется (грубо) в const m = require('m'), а каждый доступ к m компилируется в доступ к m.default
  • если true:
    • import * as m from 'm' добавляет новый объект к m, который содержит те же свойства, что module.exports, и свойство .default, указывающее на module.exports
    • import m from 'm' добавляет новый объект к m с единственный свойством .default, указывающим на module.exports. Каждый доступ к m компилируется в доступ к m.default
  • если модуль CommonJS имеет свойство .__esModule, то он всегда импортируется без учета esModuleInterop

Нужна ли нам эта настройка? Нет, если мы работаем только с модулями ESM.


❯ 11. Другие настройки с хорошими значениями по умолчанию


Обычно, мы не трогаем следующие настройки:


  • moduleDetection — эта настройка влияет на то, как TS определяет, является файл скриптом или модулем. Дефолтное значение auto хорошо работает в большинстве случаев. Значение force требуется только в случае, когда в кодовой базе есть модуль, который не содержит ни импортов, ни экспортов. Если module имеет значение Node16 и package.json содержит "type": "module", то такие файлы будут автоматически считаться модулями
  • skipLibCheck — до тех пор, пока вы не трогаете файлы определений типов библиотек, эту настройку лучше не активировать (у ее активации много недостатков)

❯ 12. Настройки TS в package.json


TS учитывает несколько свойств package.json:


  • type — это важная настройка. Если код компилируется в модули ESM, то package.json должен содержать:

"type": "module"

  • exports определяет, какие файлы является публично доступными, и переопределяет пути (пути, которые видит импортер, будут отличаться от внутренних путей). Эти настройки могут применяться условно, в зависимости от среды выполнения (браузер, Node.js и т.д.). Более подробную информацию можно найти в этой статье
  • imports позволяет определять синонимы, такие как #util для внешних модулей и пакетов. Более подробную информацию об этом свойстве можно найти здесь

❯ 13. VSCode


Если вы недовольны спецификаторами модулей для локальных импортов в автоматически создаваемых импортах, можете взглянуть на следующие настройки VSCode:


  • javascript.preferences.importModuleSpecifierEnding
  • typescript.preferences.importModuleSpecifierEnding

В настоящее время VSCode достаточно умный, чтобы добавлять расширения файлов при необходимости.


❯ 14. Заключение


Результатом моих хождений по мукам является такой tsconfig.json:


{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    // Определяем явно (не полагаемся на include):
    "rootDir": ".",
    "outDir": "dist",

    //===== Результат: JavaScript =====
    "target": "ES2024",
    "module": "Node16", // устанавливает "moduleResolution"
    // Разрешаем импорт пустых модулей
    "noUncheckedSideEffectImports": true,
    //
    "sourceMap": true, // .js.map files

    //===== Совместимость с внешними инструментами =====
    // Помогает инструментам, компилирующим .ts в .js, делая
    // модификаторы `type` обязательными для импорта типа и т.д.
    "verbatimModuleSyntax": true,

    //===== Проверка типов =====
    "strict": true, // активирует несколько полезных настроек
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "noUncheckedIndexedAccess": true,

    //===== Другие настройки =====
    // Разрешаем импорт файлов JSON
    "resolveJsonModule": true,
  }
}

14.1. Пакет npm (библиотеки и т.п.)


"compilerOptions": {
  // ···
  //===== Результат: определения =====
  "declaration": true, // файлы .d.ts
  // "Go to definition" переходит к исходнику TS source и т.д.
  "declarationMap": true, // файлы .d.ts.map

  //===== Совместимость с внешними инструментами =====
  // Помогает инструментам, компилирующим .ts в .d.ts, делая обязательными аннотации типов
  // возвращаемых экспортируемыми функциями значений и т.д.
  "isolatedDeclarations": true,

  //===== Другое =====
  "lib": ["ES2024"], // не предоставляет типы для DOM
}

Обратите внимание: если библиотека использует DOM, lib следует удалить.


Я всегда включаю настройку isolatedDeclarations, но TS разрешает это только если активирована настройка declaration или настройка composite. Jake Bailey объясняет, с чем это связано.


14.2. Приложение Node.js


"compilerOptions": {
  // ···
  //===== Другое =====
  "lib": ["ES2024"], // не предоставляет типы для DOM
}

14.3. Веб-приложение


module: 'Node16' должна хорошо работать для сборщиков. Но можно переключиться на module: 'Preserve', предназначенный специально для сборщиков.


14.4. Прямой запуск TS без генерации файлов JS


"compilerOptions": {
  "allowImportingTsExtensions": true,
  // Требуется только при компиляции в JS
  "rewriteRelativeImportExtensions": true,
}

14.5. Использование TS только для проверки типов


"compilerOptions": {
  "noEmit": true,
}

15. tsconfig.json от других авторов





Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале