JavaScript и TypeScript
Тот JavaScript, который действительно важен, плюс типы, которые ловят баги до релиза.
За 30 секунд
JavaScript — язык браузера и Node.js. TypeScript — это JavaScript с системой типов поверх: вы описываете форму данных, а компилятор проверяет каждое их использование ещё до запуска кода. Типы исчезают на этапе сборки, поэтому на выходе получается обычный JavaScript, только с гораздо меньшим числом сюрпризов в продакшене.
Почему я это использую
- Я использую TypeScript почти во всём, что делаю, — от витрин на Next.js до API на NestJS и приложений на React Native, — потому что одно неправильное имя поля должно падать в редакторе, а не перед клиентом.
- Типы для меня — живая документация: когда меняется ответ бэкенда, компилятор показывает каждый экран и сервис, которые нужно поправить следом.
- Я включаю strict mode с первого дня. Внедрять его позже в большую кодовую базу стоит гораздо дороже, чем начать с него.
- Основы JavaScript (замыкания, промисы, event loop) я по-прежнему считаю настоящим фундаментом. TypeScript не меняет того, как выполняется код, — только то, насколько безопасно вы его пишете.
Как это работает
01Основы JavaScript, которые действительно важны
Прежде чем браться за типы, разберитесь со средой выполнения. В JavaScript есть примитивные значения (string, number, boolean, null, undefined, bigint, symbol) и объекты, которые передаются по ссылке: две переменные могут указывать на один и тот же объект, поэтому изменение через одну видно через другую. По умолчанию используйте const, а let — когда нужно переприсваивание; избегайте var. Замыкание — это функция, которая помнит переменные из места, где была создана, даже после того как внешняя функция завершилась. На замыканиях построены колбэки, React-хуки, debounce и приватное состояние. Важно и сравнение: всегда используйте === (строгое равенство), потому что == выполняет запутанные преобразования типов. Наконец, освойте современный синтаксис, который вы будете читать каждый день: деструктуризацию, spread, optional chaining (a?.b) и nullish coalescing (a ?? fallback), который подставляет значение по умолчанию только для null или undefined — в отличие от ||, который заменяет ещё и 0, и пустые строки.
function makeCounter() {
let count = 0; // private, captured by the closure
return () => ++count;
}
const next = makeCounter();
next(); // 1
next(); // 2
const user = { name: "Ana", address: null };
const city = user.address?.city ?? "Unknown"; // "Unknown"
const items = 0 || 10; // 10 (|| replaces 0)
const safe = 0 ?? 10; // 0 (?? keeps 0)
02Асинхронный код и event loop
JavaScript выполняет ваш код в одном потоке. Медленная работа — сетевые запросы, таймеры, чтение файлов — передаётся браузеру или Node.js, и по её завершении в очередь ставится колбэк. Event loop берёт работу из очереди, только когда стек вызовов пуст. Основных очередей две: микрозадачи (колбэки промисов, продолжения после await) и макрозадачи (setTimeout, I/O, события UI). После каждой задачи выполняются все микрозадачи, и только потом следующая макрозадача. Поэтому зарезолвленный промис выводит лог раньше, чем setTimeout на 0 мс. async/await — это синтаксис поверх промисов: await приостанавливает эту функцию, а не всю программу. Отсюда два практических правила. Запускайте независимые запросы параллельно через Promise.all, а не ждите их по одному, и никогда не блокируйте поток тяжёлыми синхронными циклами, потому что ничто другое (клики, другие запросы) не выполнится, пока они не закончатся.
console.log("1 sync");
setTimeout(() => console.log("4 macrotask"), 0);
Promise.resolve().then(() => console.log("3 microtask"));
console.log("2 sync");
// Parallel, not sequential:
async function loadDashboard(id: string) {
const [user, orders] = await Promise.all([
fetch(`/api/users/${id}`).then((r) => r.json()),
fetch(`/api/orders?user=${id}`).then((r) => r.json()),
]);
return { user, orders };
}
03Типы: описываем форму данных
Тип — это обещание о значении: это строка, у этого объекта есть id и email, эта функция возвращает число. TypeScript проверяет эти обещания на этапе компиляции. Чаще всего вы будете использовать type-алиасы и интерфейсы для формы объектов, union-типы для «одно из» ("draft" | "paid" | "cancelled") и литеральные типы для точных значений. Для локальных переменных пусть работает вывод типов, а явно пишите типы на границах: параметры функций, возвращаемые типы публичных функций, ответы API и props компонентов. Избегайте any — он выключает проверку; для ещё не проверенных значений используйте unknown, потому что unknown заставляет проверить значение перед использованием. Оператор satisfies удобен для объектов конфигурации: он проверяет значение на соответствие типу, сохраняя при этом точные выведенные литеральные типы.
type OrderStatus = "draft" | "paid" | "cancelled";
interface Order {
id: string;
total: number;
status: OrderStatus;
note?: string; // optional
}
function label(order: Order): string {
return `#${order.id} - ${order.status}`;
}
const routes = {
home: "/",
orders: "/orders",
} satisfies Record<string, `/${string}`>;
04Сужение типов и discriminated unions
Сужение (narrowing) — это то, как TypeScript следит за вашими проверками. Внутри if (typeof x === "string") компилятор знает, что x — строка. Он понимает typeof, instanceof, оператор in, проверки на равенство, truthiness и ваши собственные type predicates (функции, возвращающие value is Type). Самый мощный паттерн — discriminated union: несколько объектных типов имеют общее литеральное поле, например kind или status, и switch по этому полю сужает тип ровно до одного варианта. Добавьте проверку never в ветку default, и компилятор сломает сборку в тот день, когда кто-то добавит новый вариант и забудет его обработать. Так баги вида «забыли один случай», очень частые в потоках оплаты и заказов, превращаются в ошибки компиляции.
type PaymentResult =
| { kind: "success"; transactionId: string }
| { kind: "declined"; reason: string }
| { kind: "unknown" }; // gateway timed out
function message(result: PaymentResult): string {
switch (result.kind) {
case "success":
return `Paid: ${result.transactionId}`;
case "declined":
return `Declined: ${result.reason}`;
case "unknown":
return "Checking with the bank...";
default: {
const unreachable: never = result; // new variant = compile error
return unreachable;
}
}
}
05Дженерики и utility-типы
Дженерик — это параметр типа: заглушка, которая заполняется, когда функция или тип используются. Он позволяет написать один фрагмент кода, работающий со многими типами, не теряя информации. Функция, которая загружает JSON, может возвращать Promise<T>, и вызывающий код получит User или Order, а не any. Ограничения (T extends { id: string }) говорят, что как минимум должно быть в типе. В TypeScript есть встроенные utility-типы на основе дженериков: Partial<T> делает все поля необязательными (удобно для payload'ов обновления), Pick и Omit выбирают поля, Record<K, V> описывает словари, ReturnType и Awaited извлекают типы из функций. Помните, что приведение дженерика — это не валидация: данные из сети нужно проверять во время выполнения библиотекой схем вроде Zod или Valibot, прежде чем доверять их типу.
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T; // validate with a schema in real code
}
function byId<T extends { id: string }>(items: T[]): Record<string, T> {
return Object.fromEntries(items.map((item) => [item.id, item]));
}
type User = { id: string; email: string; name: string };
type UserUpdate = Partial<Omit<User, "id">>;
const user = await getJson<User>("https://api.example.com/me");
06Strict mode и главное в tsconfig
tsconfig.json определяет, насколько строг компилятор и как он понимает модули. Самая важная опция — strict: true, которая включает strictNullChecks (null и undefined нужно обрабатывать), noImplicitAny и ещё несколько проверок. Начиная с TypeScript 6.0, strict включён по умолчанию для новых конфигураций, и TypeScript 7.0 (новый компилятор на Go, вышедший в 2026 году и примерно в десять раз более быстрый на больших сборках) сохраняет это значение по умолчанию. Добавьте noUncheckedIndexedAccess, чтобы array[i] и обращения к словарям включали undefined. Для приложений, которые собирает бандлер (Next.js, Vite), используйте module "esnext" с moduleResolution "bundler"; для пакетов Node.js — "nodenext". Включите verbatimModuleSyntax, чтобы импорты только типов были явными, и установите noEmit, когда саму компиляцию выполняет бандлер. Запускайте tsc --noEmit в CI, чтобы ошибка типов блокировала мерж.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
Когда применять
- Любой проект, который проживёт дольше нескольких недель или над которым будет работать больше одного человека.
- Код на границах: API-клиенты, общие пакеты, монорепозитории, где бэкенд и несколько фронтендов делят типы.
- Предметные области с множеством состояний и правил — заказы, платежи, подписки, — где discriminated unions ловят пропущенные случаи.
- Крупные рефакторинги: компилятор выдаёт полный список всего, что ломает изменение.
Когда не стоит
- Одноразовый скрипт на десять строк или быстрый прототип, который вы удалите завтра; хватит обычного JavaScript (при желании с типами в JSDoc).
- Как замена валидации во время выполнения: типы исчезают при сборке, поэтому пользовательский ввод и ответы API всё равно нужно проверять.
Альтернативы
- Обычный JavaScript + JSDoc
Комментарии с типами, которые проверяет движок TypeScript без шага сборки; хорошо для небольших библиотек и скриптов.
- Flow
Тайпчекер для JavaScript от Meta; всё ещё используется в некоторых внутренностях React Native, но экосистема в основном перешла на TypeScript.
- Kotlin/JS, Dart, ReScript
Языки, компилируемые в JavaScript, с более сильными гарантиями ценой меньшей экосистемы и непривычного для большинства веб-команд синтаксиса.
Лучшие практики
- Включайте strict и noUncheckedIndexedAccess в начале проекта; запускайте tsc --noEmit в CI.
- Явно типизируйте границы (props, сигнатуры функций, ответы API), а локальные переменные оставьте выводу типов.
- Предпочитайте unknown вместо any и проверяйте внешние данные библиотекой схем, прежде чем доверять их типу.
- Моделируйте состояния через discriminated unions и добавляйте проверку never, чтобы новые случаи нельзя было забыть.
- Держите один источник правды для общих типов — общий пакет или типы, сгенерированные из OpenAPI или схемы базы данных.
- Используйте Promise.all для независимой асинхронной работы и всегда обрабатывайте отклонённые промисы.
Частые ошибки
- Расставлять any и приведения as, чтобы заглушить ошибки, — так прячутся именно те баги, которые TypeScript должен был ловить.
- Доверять типизированному ответу API без проверки во время выполнения и падать, когда бэкенд присылает null.
- Ждать независимые запросы один за другим, делая страницу в несколько раз медленнее, чем нужно.
- Использовать || для значений по умолчанию и случайно заменять валидные значения вроде 0 или пустой строки.
- Забывать, что объекты передаются по ссылке, и мутировать состояние, которое всё ещё читает другой код.
Глубже
07Структурная типизация, проверка лишних свойств и branded-типы
TypeScript сравнивает типы по форме, а не по имени. Если у объекта есть все свойства, которые требует тип, его можно присвоить, даже если он был объявлен как что-то другое. Это называется структурной типизацией, и именно поэтому UserId и OrderId, которые оба являются обычными строками, можно перепутать без единой ошибки. Проверка лишних свойств — узкое исключение: объектный литерал, написанный прямо там, где ожидается тип, не может содержать неизвестных ключей, но тот же объект, переданный через переменную, принимается. Когда у двух значений одинаковая форма, но смешивать их нельзя никогда, используйте branded-тип: пересеките примитив с уникальным свойством-маркером, которое существует только на уровне типов. Небольшая функция-конструктор становится единственным способом создать такой бренд, так что валидация и типизация живут в одном месте. В рантайме бренды ничего не стоят, потому что маркер стирается вместе с остальными типами.
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function toUserId(raw: string): UserId {
if (!/^usr_[a-z0-9]+$/.test(raw)) throw new Error("Invalid user id");
return raw as UserId; // the only cast, guarded by validation
}
declare function cancelOrder(id: OrderId): Promise<void>;
const id = toUserId("usr_42");
// cancelOrder(id); // error: UserId is not assignable to OrderId
08Программирование на уровне типов: conditional, mapped и template literal типы
Утилитарные типы вроде Partial и ReturnType — не магия: они построены из трёх возможностей, которыми вы можете пользоваться сами. Условный тип (T extends U ? X : Y) выбирает тип в зависимости от другого, а infer захватывает часть совпавшего типа — именно так ReturnType достаёт результат функции. Когда T — union, условный тип распределяется по каждому члену отдельно; чтобы это остановить, оберните обе стороны в квадратные скобки ([T] extends [U]). Mapped-тип ({ [K in keyof T]: ... }) преобразует каждое свойство, а переназначение ключей через as позволяет переименовывать или фильтровать ключи. Template literal типы строят строковые типы, например `on${Capitalize<K>}` для имён обработчиков событий. Применяйте всё это в библиотечном и инфраструктурном коде — типизированные API-клиенты, хелперы для форм, event emitter'ы, — где один хитрый тип избавляет от сотен написанных вручную. В прикладном коде используйте их редко: тип, для объяснения которого нужна доска, — это расходы на сопровождение.
// Pick only the keys whose values are functions
type MethodKeys<T> = {
[K in keyof T]: T[K] extends (...args: never[]) => unknown ? K : never;
}[keyof T];
// Turn { click: MouseEvent } into { onClick: (e: MouseEvent) => void }
type Handlers<E> = {
[K in keyof E & string as `on${Capitalize<K>}`]: (event: E[K]) => void;
};
type Unwrap<T> = T extends Promise<infer V> ? V : T;
type A = Handlers<{ click: MouseEvent; keydown: KeyboardEvent }>;
// { onClick: (event: MouseEvent) => void; onKeydown: (event: KeyboardEvent) => void }
type B = Unwrap<Promise<number>>; // number
09Дыры в soundness, о которых стоит знать
TypeScript намеренно не является полностью sound: некоторые программы проходят проверку типов и всё равно падают в рантайме. Знание этих дыр подсказывает, где нужно быть начеку. Массивы ковариантны, поэтому Dog[] можно передать туда, где ожидается Animal[], и вызываемая функция может положить в него Cat. Параметры, объявленные синтаксисом метода (save(x: T): void), проверяются бивариантно даже при strictFunctionTypes; синтаксис свойства (save: (x: T) => void) проверяется строго, поэтому предпочитайте его в интерфейсах, которые вы контролируете. Утверждения типа (as), non-null оператор (!) и any обходят проверки. Доступ по индексу без noUncheckedIndexedAccess делает вид, что любой ключ существует. Сужение свойства объекта не сбрасывается вызовами функций, поэтому свойство, проверенное до await или колбэка, к моменту использования может уже измениться. Наконец, enum'ы и namespace'ы генерируют рантайм-код, в отличие почти от всего остального; опция erasableSyntaxOnly (TypeScript 5.8) запрещает их, чтобы код мог выполняться через простое удаление типов (type stripping), как теперь по умолчанию делает Node.js.
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
function addCat(list: Animal[]) {
list.push({ name: "Cat" }); // allowed: arrays are covariant
}
const dogs: Dog[] = [{ name: "Rex", bark() {} }];
addCat(dogs);
dogs[1].bark(); // type-checks, crashes at runtime
// Safer: accept a readonly view when you only read
function names(list: readonly Animal[]) {
return list.map((a) => a.name);
}
10Производительность компилятора в больших кодовых базах
В большом монорепозитории время проверки типов становится проблемой developer experience. Начните с измерений: tsc --extendedDiagnostics выводит время проверки, память и количество созданных типов, а --generateTrace записывает трейс, который можно открыть в инструменте производительности браузера и найти файлы и типы, съедающие больше всего времени. Типичные причины — огромные union'ы (тысячи строковых литералов), глубоко рекурсивные условные типы и barrel-файлы, из-за которых каждый модуль импортирует всё подряд. Разделите репозиторий с помощью project references: у каждого пакета composite: true и вывод деклараций, а tsc -b собирает только изменившееся, переиспользуя файлы .tsbuildinfo от инкрементальных сборок. isolatedDeclarations (TypeScript 5.5) требует явных типов у экспортов, чтобы другие инструменты могли генерировать .d.ts параллельно, не запуская checker. Предпочитайте интерфейсы, расширяющие друг друга, большим пересечениям, потому что компилятор лучше кэширует отношения между интерфейсами. Нативный компилятор TypeScript 7 делает почти всё это намного быстрее, но те же структурные советы по-прежнему актуальны.
# Where does the time go?
npx tsc --noEmit --extendedDiagnostics
npx tsc --noEmit --generateTrace ./ts-trace
# Monorepo: build only what changed, in dependency order
npx tsc -b packages/api packages/web --verbose
# packages/shared/tsconfig.json (excerpt)
# "composite": true, "declaration": true,
# "incremental": true, "isolatedDeclarations": true
Реальные ситуации
Бэкенд переименовал поле, и продакшен сломался
Проблема
API начал возвращать totalAmount вместо total. Фронтенд прекрасно скомпилировался, потому что ответ был типизирован через каст, и экран заказа показывал NaN всем клиентам, пока не выкатили хотфикс.
Как решить
Относитесь к сети как к недоверенному вводу. Опишите ответ схемой (Zod или Valibot) и выведите из неё TypeScript-тип через z.infer, чтобы одно определение управляло обоими. Парсите каждый ответ в едином слое API-адаптера: при ошибке логируйте путь проблемы и сырой payload в error tracking и показывайте понятную ошибку вместо NaN. Ещё лучше — генерируйте типы из OpenAPI-документа бэкенда в CI, чтобы переименованное поле ломало сборку фронтенда ещё до деплоя. Добавьте контрактный тест, который прогоняет схему на записанном реальном ответе. Касты вроде as Order после этого должны встречаться только внутри этого адаптера.
Легаси-кодовая база с 3,000 ошибок типов в strict-режиме
Проблема
Команда хочет strict-режим, но его включение даёт тысячи ошибок в сотнях файлов. Никто не может остановить работу над фичами на месяц, чтобы всё это исправить.
Как решить
Включите strict в основном tsconfig, чтобы весь новый код ему соответствовал, а существующие ошибки зафиксируйте как baseline, вместо того чтобы исправлять их разом. Скрипт запускает tsc, нормализует вывод по файлу и сообщению и валит CI только на ошибках, которых нет в baseline. Каждый раз, когда кто-то трогает файл, он исправляет его ошибки, и baseline уменьшается. Отслеживайте счётчик на дашборде. Начинайте с самых ценных модулей (слой API, логика денег и заказов), а старые, редко редактируемые экраны оставьте напоследок. Запретите новые any и ts-ignore правилом ESLint, разрешая ts-expect-error только с письменным обоснованием.
Проверка типов в CI занимает четыре минуты
Проблема
Монорепозиторий с API, двумя веб-приложениями и общими пакетами запускает один tsc на всё. CI медленный, а редактор зависает на секунды после каждого изменения в общем пакете.
Как решить
Сначала измерьте через --extendedDiagnostics и --generateTrace, чтобы понять, распределена ли нагрузка равномерно или её создают несколько патологических типов. Затем разделите репозиторий на project references: общие пакеты получают composite и вывод деклараций, приложения ссылаются на них, а CI запускает tsc -b, так что неизменённые пакеты пропускаются благодаря закэшированным файлам .tsbuildinfo. Уберите barrel-файлы, реэкспортирующие пакеты целиком, замените гигантские сгенерированные union'ы на более простые строковые типы там, где точность не нужна, и добавьте явные типы возвращаемых значений экспортируемым функциям, чтобы checker делал меньше вывода типов между пакетами. Переход на нативный компилятор TypeScript 7 после этого становится дополнительным ускорением, а не единственным решением.
Шпаргалка
- Проверка типов без генерации файлов
npx tsc --noEmit- Найти медленные типы
npx tsc --noEmit --extendedDiagnostics --generateTrace ./ts-trace- Инкрементальная сборка монорепозитория
npx tsc -b packages/* --verbose- Branded-тип для id
type UserId = string & { readonly __brand: "UserId" };- Защита исчерпывающего switch
default: { const _x: never = value; return _x; }- Вывести тип из рантайм-схемы
type User = z.infer<typeof UserSchema>;- Сохранить литеральные типы в конфиге
const config = { mode: "live" } as const satisfies Config;- Запретить вывод типа из одного аргумента
function pick<T>(items: T[], fallback: NoInfer<T>): T- Автоматический dispose ресурса
await using conn = await pool.connect(); // needs Symbol.asyncDispose- Запустить .ts-файл в свежем Node.js
node src/script.ts // types stripped, not checked
В моих реальных проектах
- Мультитенантный POS SaaS, который я соруковожу, написан на TypeScript целиком: API на NestJS 11 с Prisma поверх 181 модели базы данных, приложения на React 19, кассовое приложение на Electron и сайт на Next.js в одном монорепозитории.
- Интернет-магазин зоотоваров работает на Next.js 16 с TypeScript в strict mode — примерно 600 файлов TS/TSX.
- В приложении для заказа кофе на React Native post-edit хук запускает tsc и ESLint и сообщает только о новых ошибках относительно зафиксированного baseline, поэтому долг по типам может только уменьшаться.
- То же приложение нормализует ответы бэкенда в одном типизированном слое-адаптере, поэтому переименование поля на бэкенде никогда не затрагивает экраны.
Вопросы и ответы
Q1Чем any отличается от unknown?
Оба могут хранить любое значение, но ведут себя противоположно. any выключает проверку типов: у него можно вызывать методы, передавать его куда угодно, а ошибки появятся только во время выполнения. К тому же он «расползается»: всё, что получено из any, тоже становится any. unknown — безопасная версия: компилятор позволяет положить в него что угодно, но не даёт использовать, пока вы не сузите тип через typeof, instanceof, type predicate или валидацию схемой. Я использую unknown для JSON из сети, пойманных ошибок и сторонних колбэков, а any считаю code smell, которому нужен комментарий с объяснением, зачем он здесь.
Q2Объясните event loop. Почему колбэк промиса выполняется раньше setTimeout(fn, 0)?
JavaScript работает в одном потоке со стеком вызовов. Асинхронная работа делегируется хост-среде (браузеру или Node.js), и по её завершении колбэк ставится в очередь. Event loop берёт работу из очередей, только когда стек пуст. Колбэки промисов и продолжения после await попадают в очередь микрозадач; таймеры, I/O и события UI — это макрозадачи. После каждой макрозадачи движок полностью опустошает очередь микрозадач и только потом берёт следующую макрозадачу. Поэтому после завершения текущего скрипта сначала выполняется зарезолвленный промис, а затем таймер. Практический вывод: долгая синхронная работа блокирует всё, а бесконечная цепочка микрозадач может «заморить» рендеринг.
Q3Делает ли TypeScript мой код быстрее или безопаснее во время выполнения?
Напрямую — нет. TypeScript стирается при сборке, поэтому выполняемый JavaScript такой же, какой вы написали бы вручную, — без дополнительных проверок и без выигрыша в скорости. Польза — в более ранней обратной связи: целый класс багов (неверные имена свойств, необработанный null, забытые варианты union, неправильный порядок аргументов) ловится в редакторе или CI, а не в продакшене. Для настоящей безопасности на границах — ввод в формах, ответы API, переменные окружения — данные всё равно нужно валидировать библиотекой схем. Лучше всего это работает, когда схема сама порождает TypeScript-тип, — тогда источник правды один.
Q4Что такое дженерики и когда их использовать?
Дженерики — это параметры типов. Они позволяют функции, классу или типу работать со многими типами, сохраняя точную информацию о типе. Вместо fetch-хелпера, возвращающего any, getJson<T> возвращает Promise<T>, и вызывающий код получает типизированный User или Order. Ограничения вроде T extends { id: string } говорят, что тип должен содержать как минимум, — так функция может безопасно читать item.id. Я использую дженерики в переиспользуемых строительных блоках: API-клиентах, репозиториях, хелперах для форм, компонентах списков и сторах состояния. Если функция всегда работает только с одним типом, дженерик добавляет лишь шум, поэтому там я его не использую.
Q5interface или type — что выбрать?
Для формы объектов они почти взаимозаменяемы, и единообразие в кодовой базе важнее самого выбора. Интерфейсы можно расширять через extends и объединять, объявив их дважды, — это полезно для дополнения типов библиотек, например глобального Window или объекта запроса фреймворка. Type-алиасы гибче: они могут описывать union, кортежи, mapped и conditional types, чего интерфейсы не умеют. Моё правило простое: type-алиасы — для union и вычисляемых типов, интерфейсы — для публичных объектных контрактов, которые другие могут расширять. Что бы вы ни выбрали, не смешивайте стили хаотично, иначе ревьюерам придётся гадать, намеренна ли разница.
Q6Как внедрить TypeScript в существующий JavaScript-проект?
Постепенно. Сначала добавьте tsconfig с allowJs и выключенным checkJs, чтобы TypeScript компилировался рядом с существующими файлами без ошибок. Переименовывайте файлы в .ts по одному модулю, начиная с «листовых» утилит и слоя API, потому что типы оттуда растекаются по всему остальному. Рано включите strict для новых файлов, даже если старым нужны временные подавления, и следите за количеством комментариев ts-expect-error, чтобы оно только уменьшалось. Сразу добавьте tsc --noEmit в CI, чтобы новые ошибки типов нельзя было смержить. Проверка после редактирования, которая сообщает только о новых ошибках относительно baseline, позволяет команде двигаться дальше, не исправляя всё сразу.
Вопросы уровня senior
S1Как делить типы между бэкендом и несколькими фронтендами так, чтобы они не расходились?
Есть три основных подхода, у каждого свои компромиссы. Общий TypeScript-пакет в монорепозитории — самый простой и даёт мгновенную обратную связь, но связывает циклы релизов и работает, только если все потребители написаны на TypeScript. Генерация типов из контракта, например OpenAPI-документа или GraphQL-схемы, работает между языками и версиями ценой шага кодогенерации и контракта, который нужно держать честным; я валю CI, если сгенерированный результат устарел. Сквозной типизированный RPC вроде tRPC даёт лучшую эргономику, но жёстко привязывает клиентов к одной реализации сервера. Что бы вы ни выбрали, на границе сети одних типов недостаточно: я всё равно валидирую ответы в рантайме в одном слое адаптера, потому что задеплоенный бэкенд и старый мобильный клиент могут разойтись, что бы там ни говорил компилятор.
S2Enum'ы, union строковых литералов или const-объекты: что вы выбираете и почему?
В большей части прикладного кода я выбираю union строковых литералов или const-объект с выведенным из него union-типом, когда значения нужны ещё и в рантайме, например для выпадающего списка. Union'ы полностью стираются, естественно сериализуются в JSON, хорошо сужаются в switch и работают с erasableSyntaxOnly и type stripping в Node. Enum'ы TypeScript генерируют рантайм-объекты, числовые enum'ы принимают любое число, а const enum'ы создают проблемы при изолированной компиляции, поэтому в современные тулчейны они вписываются плохо. Паттерн const-объекта даёт те же преимущества, что и enum — автодополнение, единый источник значений, перебор через Object.values, — но без недостатков. Единственное место, где enum'ы ещё встречаются, — старые кодовые базы, где согласованность с существующим кодом может быть важнее идеального выбора.
S3Где система типов перестаёт помогать и как вы проектируете с учётом этих ограничений?
Типы описывают форму, а не поведение или истинность. Они не могут доказать, что список отсортирован, что email действительно существует, что цена неотрицательна или что внешние данные соответствуют объявленному типу. В них есть и намеренные дыры: касты, any, ковариантные массивы и непроверяемый доступ по индексу. Я учитываю это слоями. На границах рантайм-схемы превращают неизвестный ввод в доверенные типы. Внутри домена branded-типы и умные конструкторы кодируют инварианты, например положительные суммы или провалидированные id, так что невалидное значение нельзя создать, не пройдя проверку. Discriminated union'ы делают недопустимые состояния непредставимыми. Правила линтера запрещают «лазейки» или требуют для них комментарий. А то, что остаётся, покрывают тесты: бизнес-правила, порядок, тайминги и интеграцию с реальными сервисами.
Глоссарий
- Структурная типизация
- Типы совместимы, когда совпадает их форма, независимо от имён, под которыми они объявлены.
- Стирание типов
- Все аннотации типов удаляются при сборке, поэтому во время выполнения JavaScript типов не существует.
- Сужение (narrowing)
- Уточнение компилятором широкого типа до более конкретного после проверки вроде typeof или switch.
- Дискриминант
- Общее литеральное поле, например kind, по которому различаются варианты union'а.
- Branded-тип
- Примитив, объединённый с маркером, существующим только в типах, чтобы значения одинаковой формы нельзя было перепутать.
- Условный тип
- Тип, который выбирает один из двух результатов в зависимости от того, расширяет ли один тип другой.
- Вариантность
- То, как подтипизация контейнера или функции связана с подтипизацией типов внутри них.
- Файл деклараций (.d.ts)
- Файл, содержащий только информацию о типах для JavaScript-кода; используется библиотеками и project references.
- Project references
- Разделение кодовой базы на отдельно собираемые TypeScript-проекты, чтобы перепроверялись только изменённые части.
- Микрозадача
- Задание в очереди, например колбэк промиса, которое выполняется до того, как event loop возьмёт следующую задачу.
Что изучить дальше
Есть идея продукта?Давайте создадим его.
Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.