← Глава 29. Проектировать формами: делать недопустимое непредставимым Содержание Глава 31. Строгость как спектр →

Глава 30. Вывод против явности

Глава 30. Вывод против явности

Меньше аннотаций — корректнее код

Двое пишут один и тот же модуль. Первый пришёл из Java и аннотирует всё: каждой локальной переменной — тип, каждой функции — возвращаемый тип, каждому полю объекта — явную форму. Получается ровный, подробный, на вид очень надёжный код:

function summarize(order: Order): string {
  const lines: string[] = order.items.map((item: { sku: string; qty: number }): string => {
    const label: string = `${item.sku} × ${item.qty}`;
    return label;
  });
  const joined: string = lines.join(", ");
  return joined;
}

Второй доверяет компилятору и аннотирует только вход:

function summarize(order: Order) {
  const lines = order.items.map((item) => `${item.sku} × ${item.qty}`);
  return lines.join(", ");
}

Эти две функции эквивалентны до последнего проверенного типа. lines в обеих — string[], item в обеих — { sku: string; qty: number }, возврат в обеих — string. Просто во втором случае все эти типы вывел компилятор, а в первом их вручную переписал человек. Второй вариант короче, и большинство читателей назовут его чище. Пока это вопрос вкуса, и спорить не о чем.

Но вкус — не вся история. Поменяем масштаб. Вот функция, которую первый автор, по привычке, оставил без возвращаемого типа — потому что «и так видно»:

// helpers.ts
export function makeOrderSummary(order: Order) {
  return {
    id: order.id,
    label: `Заказ ${order.id}`,
    total: order.total,
  };
}

Возвращаемый тип здесь выведен: { id: string; label: string; total: number }. Им пользуются пятьдесят мест в кодовой базе. Теперь автор делает безобидную правку внутри — добавляет в объект ещё одно поле и заодно переименовывает total в amount, потому что так точнее:

export function makeOrderSummary(order: Order) {
  return {
    id: order.id,
    label: `Заказ ${order.id}`,
    amount: order.total, // было total
  };
}

Локально — ни одной ошибки. Функция корректна, компилятор доволен. А выведенный возвращаемый тип молча стал другим: total исчез, появился amount. Каждое из пятидесяти мест, читавших .total, теперь сломано — но ошибка вспыхивает не здесь, а там, в пятидесяти чужих файлах, далеко от причины. Внутреннее изменение протекло наружу через выведённый публичный тип и расцвело ошибкой в полусотне точек вызова.

Вот теперь вкус кусается. Тот же код бывает корректнее с меньшим числом аннотаций — и опаснее с меньшим, смотря где их убрать. Опущенная локальная аннотация ничего не стоит. Опущенный возвращаемый тип на экспортируемой функции отдал контракт на откуп выводу, и вывод честно поменял контракт, когда поменялось нутро. Два режима отказа смотрят в разные стороны: переаннотируешь — пишешь шум и воюешь с компилятором; недоаннотируешь — теряешь контракты и получаешь ошибки вдали от их источника. Вопрос главы — где проходит граница: когда аннотировать, а когда молчать.

Аннотация делает две работы, а не отбывает повинность

Глава 4 разобрала механику вывода — что компилятор знает и откуда. Здесь речь не о механике, а о вкусе: зачем вообще писать тип, если компилятор и так его знает. Рефлекс «объяви тип, иначе несерьёзно» — джавовский, и переносится он сюда неверно. Чтобы понять, где аннотация уместна, надо увидеть, что именно она делает — а делает она ровно две вещи.

Работа первая — зафиксировать контракт. Аннотация превращает выведённый, текучий тип в назначенный и стабильный. Без неё тип — производное от реализации: меняется нутро, меняется тип. С ней тип — обещание, которое реализация обязана сдержать, а нарушение обещания становится ошибкой в самой реализации.

// контракт зафиксирован: функция обязана вернуть string
function orderLabel(order: Order): string {
  return order.total; // Type 'number' is not assignable to type 'string'.
}

Ошибка показалась внутри orderLabel, на строке return, — там, где залегает баг. Уберите : string — и компилятор покладисто выведет возвращаемый тип number, ошибки здесь не будет, а беда всплывёт у того, кто ждал строку. Это вторая работа аннотации — локализовать ошибку. Зафиксированный тип — стенка, о которую расхождение разбивается в источнике, а не катится дальше по коду.

Обе работы ценны на границе и почти бесполезны внутри. Внутри функции выведенный тип локальной переменной живёт три строки; если он «сломается», ошибка всё равно вспыхнет рядом, в той же функции, потому что и потребитель — рядом. Контракт там фиксировать не для кого, локализовать ошибку некуда — она и так локальна. Аннотация внутри не делает ни одной из двух своих работ; она просто повторяет вслух то, что компилятор уже знает.

Отсюда два симметричных порока. Переаннотирование — тип написан там, где он ничего не фиксирует и ничего не локализует: шум в коде, лишняя работа при каждом рефакторинге и, хуже того, война с выводом, когда рукописный тип грубее выведенного и втихую теряет точность. Недоаннотирование — типа нет там, где он был нужен как контракт: реализация свободно течёт наружу, а ошибки всплывают вдали от причины, как в makeOrderSummary. Здоровая середина формулируется одной фразой: границы — явно, нутро — выводимо. Осталось разобрать, что считать границей.

Где аннотация зарабатывает место

Граница — это место, через которое тип виден кому-то, кроме автора строки. Там аннотация делает обе свои работы, и там она оправдана.

Первое и главное — публичный и экспортируемый API. Всё, что пересекает границу модуля, должно иметь назначенный тип, а не выведенный, потому что для потребителя тип — и есть контракт, единственное, на что он опирается. Вернёмся к makeOrderSummary и закроем дыру:

type OrderSummary = { id: OrderId; label: string; total: number };

export function makeOrderSummary(order: Order): OrderSummary {
  return {
    id: order.id,
    label: `Заказ ${order.id}`,
    amount: order.total,
    // Object literal may only specify known properties,
    // and 'amount' does not exist in type 'OrderSummary'.
  };
}

Теперь та же правка — переименование total в amount — ловится здесь, на месте, как нарушение объявленного контракта, а не разлетается на пятьдесят чужих файлов. Объявленный возвращаемый тип развернул ошибку на сто восемьдесят градусов: из «сломалось у всех потребителей» в «сломалось у автора». Это и есть локализация в чистом виде.

Второе — параметры функций. Их выводить почти неоткуда: функция — точка входа, у её аргументов нет инициализатора, из которого компилятор узнал бы форму. Параметр без аннотации обычно становится неявным any (при strict — прямой ошибкой), и контракт функции рассыпается. Исключение — контекстная типизация (contextual typing), когда функция передаётся туда, где её сигнатура уже известна из окружения:

const totals = orders.map((order) => order.total);
// order выведен как Order из типа orders: Order[] — аннотация была бы шумом

Здесь order аннотировать незачем: тип колбэка диктует map, контекст всё знает. Аннотация (order: Order) ничего не добавит, кроме длины.

Третье — неоднозначные инициализаторы, где вывод даёт не то, что вы имели в виду. Классика — пустой массив в составе объекта:

const cart = { items: [] }; // items выведено как never[]
cart.items.push({ sku: "A-1", qty: 2 });
// Argument of type '{ sku: string; qty: number; }' is not assignable
// to parameter of type 'never'.

Из [] вывести нечего, и в позиции, где тип фиксируется сразу, компилятор выводит never[] — массив, в который ничего нельзя положить. Аннотация здесь не повинность, а сообщение о намерении, которого в значении ещё нет:

const cart: { items: { sku: string; qty: number }[] } = { items: [] };
cart.items.push({ sku: "A-1", qty: 2 }); // теперь всё сходится

Четвёртое — намеренное расширение (widening). Иногда выведенный тип точнее, чем нужно, и аннотация расширяет его сознательно, чтобы разрешить переприсваивание:

let status: Order["status"] = "created";
status = "paid"; // нужно: статус будет меняться
// без аннотации const-инициализатор сузил бы тип, а let вывел бы широкий string

Во всех четырёх случаях аннотация делает работу: фиксирует то, что должно быть видно снаружи или решено заранее. Это не «тип на всякий случай» — это тип там, где без него теряется контракт или ломается намерение.

Где побеждает вывод

Зеркальный список — места, где аннотация не фиксирует ничего нового и потому остаётся чистым шумом.

Локальные переменные с очевидным инициализатором. Если из правой части видно всё, левая часть не должна это повторять:

const total = order.total;        // number — и так ясно
const labels = order.items.map((i) => i.sku); // string[] — выведено точно
const paid = order.status === "paid";          // boolean — очевидно

Аннотировать здесь — значит дублировать компилятор и брать на себя обязательство править эту копию при каждом изменении инициализатора. Чистая трата без выгоды: контракт фиксировать не для кого, ошибка и так была бы локальной.

Возвращаемые типы внутренних, неэкспортируемых функций. Раз функция не пересекает границу модуля, её выведенный возврат меняется вместе с реализацией — и это плюс, а не минус: рефакторинг распространяется сам, без ручной правки сигнатур.

// внутренняя — пусть тип следует за реализацией
function discountedTotal(order: Order) {
  return Math.round(order.total * 0.9);
}

Поменяете формулу — поменяется и тип, и все внутренние потребители подстроятся по выводу. Сопровождать нечего. Контракт здесь не нужен: потребители рядом, под вашим контролем, и любая поломка вспыхнет в том же модуле.

Места вызова дженериков. Параметры типа компилятор выводит из аргументов точнее и охотнее, чем человек впишет руками:

const first = orders.find((o) => o.status === "paid");
// first выведен как Order | undefined — пусть решает компилятор

Писать orders.find<Order>(...) — навязывать компилятору то, что он и так знает, и рисковать ошибиться там, где он не ошибётся. Вывод параметров дженерика — отдельная глава механики (Глава 18); по части вкуса правило простое: на месте вызова не подсказывай, если компилятор справляется сам.

Общий знаменатель всех четырёх случаев один: тип не виден никому за пределами ближайших строк, контракт фиксировать не для кого, локализовать ошибку некуда. Аннотация тут не добавляет гарантии — она добавляет работу.

satisfies: проверить, не расширяя

Между «аннотировать» и «выводить» долго казалось, что выбор бинарный: либо назначаешь тип и теряешь точность вывода, либо выводишь и теряешь проверку. Посмотрим, как это противостояние выглядит на объекте конфигурации.

Пусть есть таблица обработчиков — по одному на статус заказа:

const handlers = {
  created: (o: Order) => o.id,
  paid: (o: Order) => o.total,
  shipped: (o: Order) => o.items.length,
};

Хочется двух вещей сразу. Первое — проверить, что ключи не выходят за пределы статусов заказа: опечатка payed должна быть ошибкой. Второе — сохранить точный выведенный тип, чтобы handlers.paid возвращал number, а handlers.created — string, каждый свой.

Аннотация даёт первое ценой второго. Объявим тип — и проверка появится, но точность исчезнет:

const handlers: Record<Order["status"], (o: Order) => unknown> = {
  created: (o) => o.id,
  paid: (o) => o.total,
  shipped: (o) => o.items.length,
  // delivered и cancelled отсутствуют — это поймано, хорошо
};

const x = handlers.paid({ ... }); // тип: unknown — точность потеряна

Тип объявлен, ключи проверяются по Order["status"], отсутствие delivered ловится — но каждый обработчик расширен до объявленного (o: Order) => unknown, и точные возвраты стёрты. Аннотация расширила значение до своего типа.

as соблазняет «починить» это, но он не проверяет, а лжёт:

const handlers = {
  created: (o: Order) => o.id,
  paid: (o: Order) => o.total,
  shipped: (o: Order) => o.items.length,
  payed: (o: Order) => o.total, // опечатка
} as Partial<Record<Order["status"], (o: Order) => unknown>>;
// ошибки нет — as молча поверил, опечатка прошла

as ничего не сверяет — он переписывает убеждение компилятора. Опечатка payed проскользнула, проверка не сработала вовсе. Это худший из трёх исходов: ни точности, ни гарантии.

Нужное даёт satisfies (оператор сверки) — он проверяет значение на соответствие типу, но не расширяет его до этого типа, оставляя точный выведенный тип в силе:

const handlers = {
  created: (o: Order) => o.id,
  paid: (o: Order) => o.total,
  shipped: (o: Order) => o.items.length,
  payed: (o: Order) => o.total,
  // Object literal may only specify known properties, and 'payed' does not
  // exist in type 'Partial<Record<"created" | "paid" | "shipped" | ...>>'.
} satisfies Partial<Record<Order["status"], (o: Order) => unknown>>;

const a = handlers.created({ ... }); // тип: string
const b = handlers.paid({ ... });    // тип: number

Опечатка поймана — ключи сверены с Order["status"]. И при этом handlers.created остался string, а handlers.paid — number: точные выведенные типы сохранены, потому что satisfies только проверяет ограничение, а тип берёт из значения. Три инструмента — три разных эффекта: аннотация расширяет до объявленного типа, as лжёт без проверки, satisfies проверяет, ничего не расширяя. Противостояние вывода и явности было ложным: satisfies снимает его, давая проверку границы и точность нутра одновременно.

Глазами джависта

В Java выбора, который разбирает эта глава, попросту нет — и оттого рефлекс приезжает сюда неверным. Там аннотируешь почти всё по необходимости: тип поля, тип параметра, возвращаемый тип метода обязательны. Вывод появился поздно и только для локальных — var x = repo.find(id) — и даже он ограничен: ни литеральных типов, ни контекстной типизации в смысле TS, ни satisfies. Привычка вырабатывается одна: тип — это то, что ты пишешь руками, и пишешь всюду.

Перенесите её в TypeScript дословно — получите ровно тот шумный код из начала главы, местами воюющий с выводом. Особенно цепляется привычка к обязательному возвращаемому типу: в Java он часть синтаксиса метода, и рука ставит его на каждую функцию, включая трёхстрочные внутренние, где он только мешает рефакторингу. В TypeScript обязательность снята не из небрежности, а потому что вывод здесь сильнее и точнее джавовского — и слепое «аннотируй всё» тратит эту силу впустую.

Аналога satisfies в Java нет вовсе, и не из-за пробела в библиотеке. Типы в Java — это всегда расширение: переменная типа T забывает всё, что точнее T, и вернуть точность нечем. satisfies живёт ровно в зазоре между «проверить по типу» и «расширить до типа» — а в Java этого зазора не существует, проверка и расширение там одно и то же действие. Что разучить: «объяви тип в каждой строке». Чему научиться: «объяви тип на каждой границе». Слово то же — «тип», — а место и смысл другие.

Где вкус кусается

Правило «границы явно, нутро выводимо» — хорошая ось, но у вкуса есть края, где оно режет не там. Честно про них.

Переаннотированные возвраты блокируют рефакторинг. Лишняя аннотация — это не только шум при чтении; это якорь. Объявленный возвращаемый тип на внутренней функции придётся править вручную при каждом изменении реализации, а пока не правишь — он либо ошибочно красит код, либо, что хуже, молча держит устаревшую форму, мешая выводу распространить правку. Внутри, где контракт никому не нужен, явный возврат — чистый минус.

Аннотация к более широкому типу втихую выбрасывает точность. Это война с выводом в самом тихом её виде:

const method = "GET";                 // тип: "GET" — литерал
const method2: string = "GET";        // тип: string — точность потеряна

Первая строка дала литеральный тип "GET", годный для сужения и для проверки исчерпанности. Вторая аннотация расширила его до string — и всё, что зависело от литерала, сломается дальше по коду, без ошибки на самой этой строке. Аннотация к типу шире выведенного почти всегда теряет, а не приобретает; именно для таких случаев и существует satisfies.

Путаница satisfies / as / аннотации — самый частый источник тонких багов в этой теме. Тянет написать as там, где нужен satisfies, и получить непроверенную ложь вместо проверенной точности (см. payed выше). Запомнить разделение стоит как мантру: хочешь расширить — аннотируй; хочешь солгать под свою ответственность — as; хочешь проверить, не теряя точности — satisfies. Перепутал первое с третьим — потерял точность; второе с третьим — потерял проверку.

Выведённый тип публичного API — главные грабли главы. Их стоит назвать дважды, потому что это сильнейший довод за аннотацию: внутренний рефакторинг меняет экспортируемый тип без единой локальной ошибки, и поломка уезжает к потребителям, как в makeOrderSummary. Симметричная беда у недоаннотирования вообще: оно не падает там, где причина, — оно расцветает ошибками в местах вызова, и отлаживать приходится по следствиям. Обе беды лечит одно: аннотируй то, что пересекает границу.

Гигантские выведенные типы. В Части IV типы начинают вычисляться, и выведенный тип во всплывашке редактора порой разворачивается в нечитаемую простыню. Соблазн — дать ему имя аннотацией. Но имя — палка о двух концах: написать сложный вычисляемый тип руками часто труднее и опаснее, чем дать компилятору вывести его. Иногда правильный ход — не аннотировать, а извлечь промежуточный именованный тип; иногда — смириться с простынёй ради того, чтобы не сопровождать рукописную копию вычисления. Универсального ответа тут нет.

И последнее, важнее любого правила: единообразие команды бьёт частное правило. Если команда договорилась ставить возвращаемые типы на все экспортируемые функции — даже там, где вывод справился бы, — следуйте договору. Предсказуемость кодовой базы стоит дороже, чем сэкономленная на одной функции строка. Вкус — личное; согласованность — общее, и она выигрывает.

Аннотируй контракт, выводи остальное

Переключатель этой главы тоньше, чем в предыдущих, но щёлкает так же ясно. Перестаньте видеть в аннотации отчёт перед компилятором — строку, которую пишут, чтобы код выглядел серьёзным. Аннотация делает ровно две работы: фиксирует контракт и локализует ошибку. Где обе работы есть кому делать — на границе, — аннотируй. Где их делать не для кого — внутри — выводи и не засоряй.

Из этого складывается практика, противоположная джавовскому рефлексу. Аннотируй на границах: экспорт, публичный API, параметры функций, неоднозначные инициализаторы, намеренное расширение — там, где нужен зафиксированный, задокументированный контракт и ошибка, вспыхивающая в источнике. Нутро выводи: локальные переменные, внутренние возвраты, места вызова дженериков — там компилятор уже всё знает, и аннотация только отнимает у вывода точность и навешивает сопровождение. Когда хочется и проверить, и сохранить точность, тянись к satisfies — он снимает само противостояние. И держи в голове локальность ошибки как отдельный довод: объявленный возвращаемый тип ставит красное подчёркивание туда, где залегает баг, а не туда, куда он докатился. Не тащи сюда «аннотируй всё»: этот дефолт даёт шум и войну с выводом. В сомнении правило короткое — аннотируй экспорт, выводи локальное.

Итог

Аннотация — не повинность перед компилятором, а инструмент с двумя работами: фиксировать контракт и локализовать ошибку, — и обе нужны только на границах. Кто аннотирует границы и выводит нутро, тянется к satisfies вместо as и аннотации, тот пишет код и точнее, и чище, чем тот, кто по привычке объявляет каждый тип.

← Глава 29. Проектировать формами: делать недопустимое непредставимым Содержание Глава 31. Строгость как спектр →