Начинаем с TypeScript правильно: строгие настройки и защита от самых частых багов
Покажем, как включить строгий режим, использовать типы для доменной модели и уменьшить ошибки в рантайме. Разберём практические примеры улучшения кода.
Содержание
Начинаем с TypeScript правильно: строгие настройки и защита от самых частых багов
TypeScript часто воспринимают как «надстройку над JavaScript», которая добавляет типы ради красоты. На практике ценность TypeScript в другом: он помогает поймать ошибки до запуска и уменьшить стоимость багов в рантайме. Но это работает только тогда, когда вы включаете строгие настройки и используете типы не «по верхам», а как инструмент проектирования доменной модели.
Ниже — пошаговый разбор того, как начать с TypeScript правильно: включить строгий режим, настроить компилятор, структурировать типы (особенно для доменных сущностей) и дополнить это привычками, которые предотвращают наиболее частые классы ошибок.
Почему «просто TypeScript» не защищает от багов
У многих проектов TS включён формально: noImplicitAny выключен, strict не включён, типы местами размазаны в any, а проверки сводятся к минимальному пересечению типов. В такой конфигурации TypeScript перестаёт быть «страховкой» и превращается в косметику.
Типичная картина:
- Функции получают
anyилиunknownбез нормализации входных данных. - Ошибки в структуре объекта ловятся только во время выполнения.
- Доменная модель (например, «Заказ», «Платёж», «Пользователь») живёт в виде свободных plain-object без инвариантов.
- Логика ветвится по полям, которые могут быть
undefined— но это не отражено в типах.
Строгие настройки + нормальные типы решают эту проблему: вы «переносите» часть проверок из рантайма в компиляцию.
Строгие настройки: включаем правильный набор и понимаем последствия
Включаем strict
Минимальная цель — включить строгий режим:
{
"compilerOptions": {
"strict": true
}
}
Но strict — это агрегатор. Он включает несколько флагов, которые по-разному влияют на качество. Разберём ключевые.
Полный набор: strict + важные нюансы
Рекомендуемый базовый tsconfig.json для старта (в большинстве случаев — почти без компромиссов):
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true
}
}
noUncheckedIndexedAccess
Это один из самых практичных флагов. Без него доступ к массиву и объектам по индексу/ключу может считаться «гарантированно существующим», хотя на самом деле это не так.
Пример:
const arr = ["a", "b"];
const first = arr[0]; // string
const third = arr[2]; // string (в слабых настройках, что неверно)
С включённым noUncheckedIndexedAccess будет:
arr[0]→string | undefinedarr[2]→string | undefined
Это заставляет вас явно обработать отсутствие значения.
exactOptionalPropertyTypes
Без него опциональные свойства (foo?: string) часто ведут себя «мягко» и могут скрыть ошибки в доменной модели. С exactOptionalPropertyTypes foo?: string становится точнее: свойство может быть отсутствующим, и это отражается в типах.
Например, типы для объекта оплаты:
type Payment = {
id: string;
status: "pending" | "paid";
receiptUrl?: string; // может отсутствовать
};
Если в коде вы используете receiptUrl как строку без проверки, компилятор подскажет, что это небезопасно.
skipLibCheck
Включение skipLibCheck обычно не снижает качество ваших проверок, но ускоряет сборку, потому что не компилируются типы из node_modules. На старте это удобно: вы концентрируетесь на своём коде.
Доменная модель через типы: меньше «магии» в объектах
Строгие настройки — фундамент. Но на практике большинство ошибок появляется не из-за «слабого компилятора», а из-за того, что типы не отражают правила предметной области.
Ключевой принцип: если у доменного объекта есть инварианты — выражайте их в типах.
Пример: Заказ и статусы
Пусть есть сущность заказа. В реальном мире статусы и связанные поля зависят друг от друга.
Плохая модель (слишком свободная)
type Order = {
id: string;
status: "created" | "paid" | "shipped";
shippedAt?: string;
paidAt?: string;
};
Проблема не в том, что это «не компилируется». Проблема в том, что модель позволяет нелепые состояния:
status: "paid"при этомpaidAtотсутствует.status: "shipped"при этомshippedAtне задан.
TypeScript это не остановит.
Хорошая модель: дискриминируемые объединения (discriminated unions)
Правильнее выразить зависимость полей от статуса:
type OrderCreated = {
id: string;
status: "created";
};
type OrderPaid = {
id: string;
status: "paid";
paidAt: string; // обязательно
};
type OrderShipped = {
id: string;
status: "shipped";
paidAt: string; // так как платёж был до отправки
shippedAt: string;
};
type Order = OrderCreated | OrderPaid | OrderShipped;
Теперь компилятор сможет помогать вам в ветвлениях.
Например, обработчик:
function canCancel(order: Order): boolean {
switch (order.status) {
case "created":
return true;
case "paid":
case "shipped":
return false;
}
}
Если вы добавите новый статус — компилятор заставит проверить switch. А попытка использовать order.shippedAt вне статуса будет ошибкой.
Локальная защита от багов: обработка входных данных
Многие ошибки в рантайме происходят на границе системы: при чтении из API/БД/файла вы получаете данные, которые не обязаны соответствовать вашему типу.
TypeScript не проверяет данные во время выполнения — он лишь проверяет типы в вашем коде. Поэтому, когда вы получаете unknown/any извне, нужно делать валидацию.
Пример: функция парсинга заказа.
function assertOrder(input: unknown): Order {
if (typeof input !== "object" || input === null) {
throw new Error("Order must be an object");
}
const obj = input as Record<string, unknown>;
const id = obj.id;
const status = obj.status;
if (typeof id !== "string") throw new Error("Order.id must be string");
if (status !== "created" && status !== "paid" && status !== "shipped") {
throw new Error("Invalid order.status");
}
if (status === "created") {
return { id, status };
}
if (status === "paid") {
const paidAt = obj.paidAt;
if (typeof paidAt !== "string") throw new Error("paidAt must be string");
return { id, status, paidAt };
}
// status === "shipped"
const paidAt = obj.paidAt;
const shippedAt = obj.shippedAt;
if (typeof paidAt !== "string") throw new Error("paidAt must be string");
if (typeof shippedAt !== "string") throw new Error("shippedAt must be string");
return { id, status, paidAt, shippedAt };
}
Да, это чуть длиннее. Но это как раз тот случай, когда вы платите временем разработчика один раз — чтобы больше не разбираться с «случайными» падениями в проде.
Типы функций: как не допускать «непонятных» входов и выходов
Даже с нормальной доменной моделью типы функций часто превращаются в источник ошибок.
Проблема: any в краях системы
Обычно any появляется там, где вы не контролируете источник: fetch, request.body, сериализация и т. п.
Опасный паттерн:
function handle(req: any) {
// ...
}
Лучше:
- Считать вход как
unknown. - Применить проверку.
- Дальше работать уже с корректными типами.
Пример:
function handleBody(body: unknown) {
const data = assertOrder(body); // либо другая assert-логика
// теперь data: Order
}
Полезный приём: универсальные сигнатуры без «лишней свободы»
Если ваша функция принимает коллбек, часто нужно аккуратно ограничить типы параметров. Например, функция для обработки событий:
type Event =
| { type: "created"; orderId: string }
| { type: "paid"; orderId: string; paidAt: string }
| { type: "shipped"; orderId: string; shippedAt: string };
function dispatch<T extends Event>(event: T): T {
// например, логируем и возвращаем
return event;
}
Такая сигнатура сохраняет конкретный тип события (а не скатывается в общий Event).
Защита от частых багов: список проблем и точечные решения
Баг №1: undefined на ровном месте
С strictNullChecks и noUncheckedIndexedAccess компилятор будет ругаться на опасные места. Но есть и человеческий фактор: разработчик может «обойти» систему типов.
Плохой паттерн:
const name = user.name!;
! — это обещание компилятору, что значение не null/undefined. Если вы ошиблись — получаете рантайм-краш.
Лучше — явная проверка:
function formatUser(user: { name?: string }) {
if (!user.name) return "Anonymous";
return user.name;
}
Баг №2: неучтённые ветви switch
В хорошей модели (discriminated unions) switch — мощный инструмент. Но нужно писать его так, чтобы компилятор мог помочь.
Приём: never в финальной ветви.
function orderMessage(order: Order): string {
switch (order.status) {
case "created":
return "Order is created";
case "paid":
return `Paid at ${order.paidAt}`;
case "shipped":
return `Shipped at ${order.shippedAt}`;
default: {
const _exhaustive: never = order;
return _exhaustive;
}
}
}
Если появится новый статус и вы забудете обработку — TypeScript перестанет компилировать код.
Баг №3: смешивание «числа» и «строки» (а иногда и единиц измерения)
В JS легко перепутать amount (число) и currency (строка), или ms и s.
TypeScript частично лечит это приёмами «брендирования» (nominal typing через & { __brand: ... }).
type Money = number & { readonly __brand: "Money" };
type Milliseconds = number & { readonly __brand: "Ms" };
function ms(value: number): Milliseconds {
return value as Milliseconds;
}
function money(value: number): Money {
return value as Money;
}
Да, это не «волшебная таблетка», но это хороший стоп-кран для простых ошибок.
Баг №4: мутация данных и неожиданные побочные эффекты
Типы могут помочь и здесь: делайте значения неизменяемыми, где это уместно.
type OrderView = Readonly<{
id: string;
status: Order["status"];
}>;
Или на уровне массива/структур — использовать ReadonlyArray<T>.
Практический пример: улучшаем код по шагам (от слабого к строгому)
Возьмём типичный фрагмент: получение списка заказов и формирование отчёта.
Вариант 1: «всё как есть»
type OrderRaw = {
id: string;
status: string;
paidAt?: string;
shippedAt?: string;
};
function buildReport(orders: OrderRaw[]) {
return orders.map((o) => ({
id: o.id,
status: o.status,
shippedAt: o.shippedAt?.toUpperCase() ?? "N/A"
}));
}
Что здесь не так:
status— строка, и мы не знаем допустимые значения.- Мы используем
shippedAtкак будто это строка, но она может быть отсутствующей — мы подставляемN/A, но это может скрывать ошибки источника данных. - Нет инвариантов между статусом и датами.
Вариант 2: дискриминируем статусы
type OrderCreated = { id: string; status: "created" };
type OrderPaid = { id: string; status: "paid"; paidAt: string };
type OrderShipped = {
id: string;
status: "shipped";
paidAt: string;
shippedAt: string;
};
type Order = OrderCreated | OrderPaid | OrderShipped;
function buildReport(orders: Order[]) {
return orders.map((o) => {
switch (o.status) {
case "created":
return { id: o.id, status: o.status, shippedAt: "N/A" };
case "paid":
return { id: o.id, status: o.status, shippedAt: "N/A" };
case "shipped":
return { id: o.id, status: o.status, shippedAt: o.shippedAt.toUpperCase() };
}
});
}
Теперь:
- Невозможно создать
Orderсо статусом"paid"безpaidAt. - В
switchобработка полностью покрывает статусы. - Ошибки в данных обнаружатся на этапе
assertOrder(когда вы делаете преобразование входа к доменной модели), а не в середине логики.
Вариант 3: отделяем валидацию от бизнес-логики
function buildReportFromUnknown(input: unknown) {
const orders = assertOrders(input); // здесь вы делаете проверки
return buildReport(orders);
}
Компоновка:
- Валидация — на границе.
- Бизнес-логика — на типах доменной модели.
- Это заметно снижает сложность отладки.
Типы и “стоимость ошибки”: где именно TS даёт максимум эффекта
Строгие настройки имеют «цены» в виде необходимости писать больше кода (особенно при работе с внешними данными). Но они окупаются, когда вы:
- регулярно интегрируетесь с внешними API,
- активно используете
switchпо типам/статусам, - храните важные инварианты в доменных сущностях,
- много рефакторите и меняете структуру данных.
Если же проект маленький и вы контролируете весь код от начала до конца, TS всё равно полезен, но эффект может быть менее заметным.
Как не утонуть в типах: практичные правила
- Не используйте
anyкак универсальную “отмазку”. Если сомневаетесь — используйтеunknownи потом проверяйте. - Разделяйте границы и внутренности: на границах — валидация; внутри — строгие типы доменной модели.
- Сделайте
switchисчерпывающим сnever, когда вы работаете с discriminated unions. - Не размывайте инварианты: если поле обязательно при статусе — оно должно быть обязательно в типе.
- Включайте строгие флаги заранее. Добавлять их в середине проекта часто болезненно, потому что вы обнаруживаете сотни мест, которые раньше “проходили”.
Итог: строгие настройки и типы как архитектурный инструмент
Правильный старт с TypeScript — это не попытка «добавить типы любой ценой». Это дисциплина:
- включить строгие настройки (
strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes); - выразить инварианты предметной области через дискриминируемые объединения;
- отделить валидацию входных данных от бизнес-логики;
- писать ветвления так, чтобы компилятор защищал вас от неучтённых случаев.
Если вы хотите закрепить это системно, полезным следующим шагом может стать курс «TypeScript - для начинающих!» — как способ собрать базу и разобраться в типовых паттернах уже с примерами и разбором ошибок.
TypeScript не избавляет от багов полностью, но при строгом подходе он меняет баланс: часть проблем перестаёт быть «неожиданной» и становится «замеченной до запуска». Это и есть реальная защита разработки.
Комментарии
Пока нет комментариев