Раздел 4 · TypeScript

Из JavaScript в TypeScript: не сломать работающую CRM

middle~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Из JavaScript в TypeScript: не сломать работающую CRM

Ты открываешь маленькую CRM. Слева список компаний и людей, справа карточка с JSON. Имя компании можно изменить, сделку создать, заметку добавить. Сервер написан на Express, страница на обычном JavaScript. Сборщика нет. Тестов и описания API тоже нет.

Задание звучит коротко: «Добавь TypeScript». Но если после переименования файлов пропадут заметки или браузер перестанет открывать страницу, число исправленных ошибок компилятора никого не утешит.

При миграции у тебя два обязательства: сохранить известное поведение и сделать ограничения кода видимыми для проверки типов. Зелёный tsc отвечает только за второе. Для первого нужны наблюдения и проверки выполнения.

Урок восстановлен по расшифровке занятия. В ней есть описание CRM, действий и инструментов, но нет содержимого исходного репозитория и доступной ссылки на него. Конфиги и минимальный пример ниже реконструированы для урока, это не дословный код автора. Пример проверяет цепочку «TypeScript, JavaScript, HTTP, браузер», а не воспроизводит всю CRM. Поведение реального проекта нужно фиксировать на его исходниках.

В 04-ts/01 · Мост между JavaScript и TypeScript ты уже познакомился с разницей между значением и его типом. Здесь эта разница станет рабочим правилом: отдельно проверяй контракт, отдельно запускай программу.

1. Сначала узнай, что нельзя потерять

В исходной сцене работают карточки компаний и людей. У компании есть имя, даты, теги, произвольные поля custom и иногда домен. Сделка содержит заголовок, сумму, владельца и контакты. Заметки привязаны к объекту, а не лежат одним общим списком.

Сервер умеет больше, чем показывает интерфейс. Его механизм изменений поддерживает четыре операции:

ОперацияДействие
setЗаписать значение поля по пути
unsetУдалить поле по пути
pushДобавить элемент в массив
incУвеличить число на заданную величину

Если форма страницы вызывает только set, это не разрешает удалить остальные ветки сервера. Ими могут пользоваться интеграции или другой клиент.

В записи есть и известная ошибка: выбор сделки в списке не открывает её карточку. Не выдавай исправление этой ошибки за «добавление типов». Запиши её отдельно и исправляй отдельным изменением. Так ты сможешь объяснить, почему поменялось поведение.

Перед миграцией пройди один повторяемый сценарий:

  1. Открой компанию и запиши её id, имя, теги и custom.
  2. Измени имя через set, например на Компания после проверки.
  3. Обнови страницу и повторно загрузи компанию. Проверь новое имя в ответе сервера, а не только в поле формы.
  4. Создай сделку с заголовком Проверка миграции и суммой 1250. Запиши её идентификатор, владельца и контакты. Проверь, что сумма в JSON число, а не строка.
  5. Добавь к компании заметку Проверка до миграции. Перезагрузи страницу и проверь список заметок именно этой компании.
  6. Отдельно вызови серверные unset, push, inc на тестовых данных, даже если для них нет кнопок. Запиши путь, входные значения и результат каждой операции.

В учебной CRM после обновления страницы состояние остаётся на сервере. Это не обещание сохранения после перезапуска процесса: фейковая база может жить в памяти. Сравнивай одинаковые условия, иначе примешь сброс данных при перезапуске за ошибку TypeScript.

Сделка и компания тоже не одно и то же. Экран компании может предлагать создание сделки, но из этого не следует, что ownerId обозначает компанию. Смысл поля выясняется по бизнес-функциям и данным. В следующем уроке владелец сделки будет человеком.

2. Зафиксируй инструменты проекта

Не используй глобальный tsc как источник истины. У двух разработчиков он может иметь разные версии. Установи TypeScript в devDependencies проекта и запускай через pnpm exec или скрипты package.json.

Для самостоятельного минимального примера ниже нужны Node.js 24 и pnpm 10. Создай пустой каталог, выполни pnpm init, а затем:

pnpm add --save-exact express@5
pnpm add -D --save-exact typescript tsx @types/node@24 @types/express@5
pnpm add -D --save-exact eslint @eslint/js typescript-eslint globals
pnpm exec tsc --version

Это команды для твоего отдельного учебного каталога, не требование добавить зависимости в BatSchool. Если мигрируешь существующую CRM, не обновляй Express одновременно с добавлением типов. Подбирай декларации под установленную основную версию библиотеки.

--save-exact записывает точную разрешённую версию в package.json. pnpm-lock.yaml фиксирует конкретное дерево, включая транзитивные зависимости. Сохрани оба файла. На другой машине или в CI устанавливай зависимости так:

pnpm install --frozen-lockfile

Обновление TypeScript может добавить ошибки проверки даже внутри одной основной версии. Не рассчитывай на правило «версия начинается с того же числа, значит диагностика не изменится». Обновляй компилятор отдельным изменением, читай заметки к выпуску и сравнивай результат проверки и выполнения.

Некоторые пакеты поставляют свои типы. Для других есть декларации сообщества в DefinitelyTyped, опубликованные как @types/*. Для Express устанавливается @types/express, для Node.js @types/node.

Декларация .d.ts описывает API для компилятора. Она не устанавливает реализацию, не исполняется и не проверяет JSON во время работы. Если декларация не соответствует установленной библиотеке, красивый тип не исправит этот разрыв.

3. Раздели сервер и браузер

У CRM две среды исполнения. На сервере доступны process и модули node:*, в браузере document и DOM. Конфиг нужен каждому, иначе проверка позволит использовать API не той среды.

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

crm/
  package.json
  pnpm-lock.yaml
  tsconfig.base.json
  tsconfig.server.json
  tsconfig.browser.json
  eslint.config.mjs
  src/
    server.ts
  public/
    index.html
    app.ts
  shared/
    health.ts
  dist/                 # результат tsc, не исходники

В исходной CRM рядом с server.ts будут db.ts и patch.ts. Здесь их нет: для проверки инструментов нужна только ручка состояния сервера.

Общие правила положи в tsconfig.base.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "forceConsistentCasingInFileNames": true,
    "verbatimModuleSyntax": true,
    "noEmitOnError": true,
    "skipLibCheck": false
  }
}

Серверный tsconfig.server.json:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "types": ["node"],
    "esModuleInterop": true,
    "rootDir": ".",
    "outDir": "dist/server"
  },
  "include": ["src/**/*.ts", "shared/**/*.ts"]
}

Браузерный tsconfig.browser.json:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": [],
    "rootDir": ".",
    "outDir": "dist/client"
  },
  "include": ["public/**/*.ts", "shared/**/*.ts"]
}

Добавь в package.json поле "type": "module". Оно нужно и проверке NodeNext, и Node при запуске выпущенных .js. Скрипты добавишь чуть ниже; зависимости, которые записал pnpm, оставь на месте.

Почему у браузера тоже NodeNext? В этом маленьком примере есть только относительные ESM-импорты с расширением .js и общий файл типов. Мы выбираем ограничения импорта, совместимые с Node ESM, и используем подмножество модулей, которое понимает браузер. Это не превращает браузер в Node: его глобальные API заданы через lib и types. Не импортируй туда node:* или серверные библиотеки.

Режим moduleResolution: "Bundler" полезен в проекте со сборщиком. Здесь сборщика нет. Голый импорт вроде import x from 'some-package' браузер сам по node_modules не найдёт. Для него понадобятся сборщик, карта импортов или конкретные обслуживаемые URL. В этом уроке таких импортов в клиенте нет.

rootDir выбран как . потому, что исходники включают и src либо public, и shared. Компилятор сохраняет относительную структуру: сервер появится в dist/server/src/server.js, клиент в dist/client/public/app.js. Если поставить серверу rootDir: "src", общий файл за его пределами приведёт к ошибке.

Здесь не нужны references, composite, tsc -b и файлы .tsbuildinfo. Два независимых вызова tsc -p решают задачу. Ссылки между проектами полезны для более крупной сборки с зависимостями, но не обязательны из-за наличия второго конфига.

Что означает каждая группа настроек

Держи в голове три разных вопроса:

НастройкаНа какой вопрос отвечаетЧего не делает
targetКакой уровень синтаксиса JavaScript выпускать?Не обновляет Node и браузер
libКакие стандартные API известны проверке типов?Не добавляет полифилы и реальные API
moduleКак интерпретировать и выпускать модули?Не раздаёт файлы по HTTP
moduleResolutionКак компилятор находит модули и их типы?Не переписывает все пути под браузер
typesКакие пакеты глобальных деклараций подключать автоматически?Не запрещает все явно импортируемые типы
rootDir, outDirКак устроены пути выпущенных файлов?Не копирует HTML и картинки

ES2022 в lib позволяет описать, например, array.at(). Но наличие метода в декларациях не доказывает, что он есть в старом браузере. Если целевая среда его не поддерживает, меняй код или добавляй подходящий полифил. target не решает эту задачу.

У сервера нет DOM в lib, а у браузера types: [] не позволяет установленным @types/node автоматически принести глобальный process. Общий файл тоже держи независимым от среды. То, что он проходит оба конфига, ещё не доказывает его переносимость, если ты явно импортировал туда Node API.

Строгость и дополнительные проверки

strict: true включает группу проверок, в том числе noImplicitAny, strictNullChecks, strictFunctionTypes и useUnknownInCatchVariables. Последняя делает пойманное исключение unknown, пока ты не проверишь его форму. Не все полезные флаги входят в strict:

  • noUncheckedIndexedAccess добавляет возможность undefined для непроверенного доступа по индексу, например к элементу обычного массива.
  • exactOptionalPropertyTypes различает отсутствующее свойство и свойство со значением undefined при записи.
  • noImplicitReturns ловит незавершённые пути возврата там, где функция возвращает значение. Он не требует писать return в каждой функции, возвращающей void.
  • noFallthroughCasesInSwitch ловит случайный переход из непустого case в следующий. Пустые объединённые ветки остаются допустимыми.
  • noUnusedLocals и noUnusedParameters подсвечивают забытый код. Неиспользуемый параметр можно намеренно назвать с начальным _.
  • forceConsistentCasingInFileNames помогает не перепутать Model.ts и model.ts. Например, обычная установка macOS часто не различает регистр в путях, а Linux различает.

Вот практический эффект двух флагов:

const names: string[] = [];
const first = names[0]; // string | undefined

if (first !== undefined) {
  console.log(first.toUpperCase());
}

type ThemeOptions = { theme?: 'light' | 'dark' };
const options: ThemeOptions = {};
options.theme = 'dark';
// options.theme = undefined; // ошибка exactOptionalPropertyTypes

При чтении options.theme всё ещё может быть undefined: свойства может не быть. Чтобы разрешить явную запись undefined, объяви её в типе: theme?: 'light' | 'dark' | undefined. Не делай это автоматически для всех полей, сначала выясни смысл отсутствия.

skipLibCheck: true пропускает проверку файлов деклараций .d.ts, включая собственные. Это не приказ «не проверять исходники библиотек внутри node_modules». Использование типов библиотек в твоём коде всё равно проверяется. Флаг может ускорить проверку и скрыть конфликт деклараций; сначала выясни причину конфликта. В этом маленьком примере он выключен.

esModuleInterop помогает согласовать импорты с декларациями CommonJS-пакетов, например Express. Связанная настройка allowSyntheticDefaultImports относится к проверке типов, а не создаёт экспорт в библиотеке. В нашей ESM-сборке фактическое выполнение импорта также зависит от правил взаимодействия ESM и CommonJS в Node.

declaration выпускает .d.ts для твоего кода. Сейчас приложение не публикует библиотеку, поэтому генерировать декларации ему не нужно.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.