Частые ловушки: то, что компилируется чисто и кусает позже
Сводка senior-ловушек: `as`/`!` прячут баги, утечки `any`, структурная типизация пропускает не те объекты, `Object.keys` возвращает `string[]`, ковариантность массивов, мутация ломает сужение, забытый `as const`. Для каждой: симптом, почему компилируется, фикс.
У каждой ловушки этого урока есть общее: она компилируется чисто. Сборка зелёная, PR одобрен, типы выглядят непробиваемо — а потом userId передаётся туда, где ждали orderId, суженное значение оказывается undefined двумя строками позже, или Object.keys отдаёт переменную цикла, которой нельзя индексировать. Это баги, что переживают ревью именно потому, что проверщик типов сказал «да». Преимущество senior’а не в знании большего синтаксиса; оно в знании, где «да» проверщика несостоятельно или неточно, и в рефлекторном выборе нужного фикса.
Сводная таблица
Каждая строка — ловушка, проходящая компилятор. Читайте как: симптом, почему проскакивает и рефлекторный фикс.
| Ловушка | Почему компилируется | Фикс |
|---|---|---|
as прячет реальное несовпадение | Каст утверждает тип без рантайм-проверки; компилятор вам верит | Валидируйте на границах (zod); берегите as для знания, которого реально нет у компилятора |
Злоупотребление ! (non-null) | ! стирает null/undefined из типа без доказательства | Сужайте реальной проверкой (if (x)) или почините тип, чтобы он не включал null |
Утечка any из библиотеки | any присваивается во все стороны; распространяется тихо | Типизируйте границы библиотек как unknown и валидируйте; включите noImplicitAny |
| Структурная типизация пропускает не те ID | UserId и OrderId — оба string — структурно идентичны | Забрендируйте тип: string & { __brand: "UserId" } |
Object.keys(o) — это string[] | В рантайме могут быть лишние ключи, так что keyof был бы несостоятелен | Кастуйте намеренно: Object.keys(o) as (keyof typeof o)[] (риск принят вами) |
| Несостоятельность ковариантности массивов | Dog[] присваивается Animal[], но push Cat затем ломает | Принимайте readonly T[] в API, чтобы вызывающие не могли мутировать |
| Мутация ломает сужение | Суженный let/свойство может измениться между проверкой и использованием | Используйте const, копируйте в локальную или перепроверяйте после вызова, способного мутировать |
Забытый as const | Без него { x: 1 } расширяется до { x: number }, литералы до базового типа | Добавьте as const, чтобы сохранить литеральные типы и readonly-кортежи |
Брендинг: намеренная победа над структурной типизацией
Раздел 01 учил, что TypeScript структурен — два типа одной формы взаимозаменяемы. Обычно это фича; здесь — опасность. type UserId = string и type OrderId = string — это один и тот же тип, так что передача одного туда, где ждут другой, компилируется. Брендинг добавляет фантомное свойство, существующее только в типе:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function getUser(id: UserId) { /* ... */ }
const order = "ord_1" as OrderId;
// getUser(order); // Error: 'OrderId' is not assignable to 'UserId'
// __brand различает их, хотя оба — string в рантайме
const user = "usr_1" as UserId;
getUser(user); // ok__brand никогда не существует в рантайме (под ним string), но тип теперь различает два, так что перепутанный ID — ошибка компиляции. Это стандартный фикс для «структурная типизация пропустила не тот примитив».
Ковариантность массивов: каноническая несостоятельность
TypeScript намеренно делает массивы ковариантными — Dog[] присваивается Animal[] — ради эргономики. Но массивы изменяемы, что делает это несостоятельным:
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // разрешено — ковариантность
animals.push(new Cat()); // компилируется! Animal[] принимает Cat
dogs[1].bark(); // падение в рантайме — это Cat, нет bark()Компилятор принял каждую строку, а dogs теперь содержит Cat. Фикс в дизайне API: принимайте readonly Animal[], когда только читаете, чтобы вызывающий не мог протащить Cat в чужой Dog[].
▸Почему это работает
Почему TS выбрал несостоятельное правило? Чистая состоятельность запретила бы Dog[] там, где ждут Animal[], сломав огромный объём разумного read-only-кода (вы передаёте Dog[] функции, которая просто итерирует Animal[]). Команда разменяла узкую несостоятельность — срабатывающую только при мутации через расширенную ссылку — на повседневную эргономику. readonly-массивы восстанавливают состоятельность ровно тем, что убирают ломающую мутацию.
Мутация ломает сужение
Сужение (раздел 02) валидно лишь пока значение не может измениться. Изменяемая привязка или свойство, суженные в if, могут быть сброшены любым промежуточным вызовом, сквозь который компилятор не видит:
function f(box: { v: string | null }) {
if (box.v !== null) {
sideEffect(); // мог сделать box.v = null — компилятор считает, что нет
box.v.toUpperCase(); // ^? string — но может упасть в рантайме
}
}Компилятор сужает box.v до string внутри if и сохраняет это допущение сквозь sideEffect(), потому что не может доказать, что вызов не тронул box.v. Фикс: скопируйте в const-локальную сразу после проверки (const v = box.v;), чтобы суженное значение не вымутировали из-под вас.
`const animals: Animal[] = dogs` (где dogs — Dog[]) компилируется, затем `animals.push(new Cat())` тоже компилируется и портит dogs. Почему TypeScript это разрешает?
Расставьте рабочий процесс от диагноза к фиксу для бага, что скомпилировался чисто, но упал в рантайме:
- 1 Симптом: падение в рантайме на коде, одобренном проверщиком типов
- 2 Найти несостоятельное или неточное место: люк as/!, ковариантность, мутацию или структурное столкновение
- 3 Объяснить ПОЧЕМУ скомпилировалось — какую гарантию TS отдал (или никогда не имел) на той строке
- 4 Применить рефлекторный фикс: валидировать, забрендировать, использовать readonly/const или добавить as const
- 5 Добавить guardrail (lint-правило, более строгий флаг), чтобы тот же класс багов не повторился
- 01Зачем нужен брендинг и как `string & { __brand: 'UserId' }` работает, не влияя на рантайм?
- 02Объясните несостоятельность ковариантности массивов и фикс через readonly.
- 03Почему мутация может сломать сужение, каков рефлекторный фикс и почему Object.keys типизирован как string[]?
Теперь вы называете senior-ловушки с ходу — люки as/!, утечки any, структурные столкновения ID, расширение Object.keys, ковариантность массивов, сломанное мутацией сужение, забытый as const — и для каждой знаете симптом, почему скомпилировалось и рефлекторный фикс (валидировать, забрендировать, readonly/const, намеренные касты). Это закрывает раздел «реальный мир»: вы умеете типизировать грязные границы реальных систем, безопасно потреблять их через zod и tRPC, проектировать API библиотек, что хорошо выводятся, и замечать ловушки, что компилируются чисто. Всё сходится в капстоуне типизированной фичи в разделе 09, где вы строите одну сквозную фичу, применяя всё это сразу. Теперь, когда в ревью видите зелёную сборку на PR, где голая string ID попадает в функцию, ждущую другого вида ID, вы знаете структурное столкновение за этим — и тянетесь к бренду, прежде чем одобрить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.