Из 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, это не разрешает удалить остальные ветки сервера. Ими могут пользоваться интеграции или другой клиент.
В записи есть и известная ошибка: выбор сделки в списке не открывает её карточку. Не выдавай исправление этой ошибки за «добавление типов». Запиши её отдельно и исправляй отдельным изменением. Так ты сможешь объяснить, почему поменялось поведение.
Перед миграцией пройди один повторяемый сценарий:
- Открой компанию и запиши её
id, имя, теги иcustom. - Измени имя через
set, например наКомпания после проверки. - Обнови страницу и повторно загрузи компанию. Проверь новое имя в ответе сервера, а не только в поле формы.
- Создай сделку с заголовком
Проверка миграциии суммой1250. Запиши её идентификатор, владельца и контакты. Проверь, что сумма в JSON число, а не строка. - Добавь к компании заметку
Проверка до миграции. Перезагрузи страницу и проверь список заметок именно этой компании. - Отдельно вызови серверные
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.