← Глава 3. Стирание: почему типов нет в рантайме Содержание Глава 5. Компилятор как доказательство →

Глава 4. Вывод вместо объявления

Глава 4. Вывод вместо объявления

Почему ключевое слово влияет на тип

Две строки, одинаковое значение справа от знака равно. Наведите курсор на каждую переменную и посмотрите, что покажет редактор:

const role = "admin"; // тип: "admin"
let role2 = "admin";  // тип: string

Значение одно и то же — строка "admin". А типы разные. Не «по-разному оформлены», а буквально разные: у первой переменной тип — это конкретный строковый литерал "admin", множество из одного-единственного значения; у второй — string, множество всех мыслимых строк. Никакой аннотации ни там, ни там нет. Единственное, чем строки отличаются, — ключевое слово объявления.

Для человека из Java это сбой в картине мира. Там final String role и String role различаются ровно одним: можно ли переменной потом присвоить заново. На тип final не влияет никак — он и там, и там String. Слово, которое управляет изменяемостью, не имеет права лезть в типы; это разные оси, они не пересекаются. А здесь пересеклись, да ещё так, что от выбора const/let зависит, какие значения переменная вообще способна принять с точки зрения проверяющего.

И это лишь первая странность. Вторая в том, что аннотаций в обеих строках нет вовсе — а типы есть. Откуда? Джавист, переходя на TypeScript, приносит с собой въевшийся рефлекс: каждая переменная объявляется с типом, тип пишется слева, это дисциплина и хорошая форма. Рефлекс заставляет писать const role: string = "admin" — и этим он, во-первых, делает лишнюю работу, а во-вторых, своими руками портит тип: было "admin", стало string. Привычка, которая в Java была добродетелью, здесь воюет с языком.

Чтобы понять обе странности разом, надо посмотреть, откуда вообще берётся тип, когда его не написали.

Компилятор уже знает тип

Тип переменной не обязан приходить из аннотации. Если есть инициализатор — выражение справа от =, — компилятор берёт тип оттуда. Это вывод типов (type inference): проверяющий смотрит на значение, которым переменную инициализируют, и сам приписывает ей тип, не дожидаясь, пока вы его продиктуете.

const total = 500;      // выведен тип: number
const id = "A-1";       // выведен тип: "A-1"
const paid = true;      // выведен тип: boolean
const items = [1, 2];   // выведен тип: number[]

Писать const total: number = 500 не запрещено, но это повторение того, что компилятор и так видит. Значение 500 — число; чему ещё быть типу переменной, как не number. Аннотация здесь не добавляет ни проверки, ни информации — она дублирует очевидное, и редактор покажет ровно тот же number, что вывелся бы сам.

Теперь вернёмся к расхождению const/let. Когда компилятор выводит тип из литерала, он стоит перед выбором, и выбор зависит от того, сможет ли значение измениться.

Возьмём const. Переменная, объявленная через const, переприсваиванию не подлежит — раз связали, навсегда. Значит, если справа стоит литерал "admin", эта переменная всегда будет ровно "admin" и ничем иным. Проверяющему незачем расширять тип: самый точный тип, какой он может приписать, — это литеральный тип (literal type) "admin", множество из одного значения. Он так и делает.

const role = "admin"; // тип: "admin" — измениться не сможет, значит, фиксируем точно

Теперь let. Такую переменную можно переприсвоить позже:

let status = "created";
status = "paid"; // законно — let допускает переприсваивание

Если бы компилятор вывел для status тип "created", вторая строка не прошла бы проверку: "paid" не входит в множество {"created"}. Но интуиция говорит, что код правильный — переменной для того и дали let, чтобы менять. Поэтому при выводе из литерала под let компилятор расширяет литеральный тип до базового: "created" превращается в string, 500 — в number, true — в boolean. Это называется расширение литералов (literal widening): проверяющий жертвует точностью ради того, чтобы изменяемая переменная могла принимать другие значения того же рода.

let status = "created"; // тип: string — переменную ещё будут менять, фиксировать точно нельзя
status = "paid";        // ок
status = 42;            // ошибка: Type 'number' is not assignable to type 'string'

Вот и разгадка первой странности. Ключевое слово влияет на тип не напрямую, а через изменяемость: const обещает, что значение не сменится, и компилятор имеет право вывести самый узкий тип; let такого обещания не даёт, и компилятор вынужден расширить тип до базового, чтобы будущие присваивания были законны. Один и тот же литерал даёт разный тип, потому что в одном случае он застыл навсегда, а в другом — лишь первое значение из многих.

Стоит уточнить, что const сохраняет литеральный тип только для примитивов. С объектом всё иначе, и это отдельная ловушка, к которой мы вернёмся в разделе про острые края.

as const: заморозить до самой узкой формы

Расширение под let логично. Но иногда узкий, литеральный тип нужен и там, где const его не сохраняет, — внутри объектов и массивов. Возьмём конфигурацию запроса к платёжному шлюзу:

const config = {
  method: "POST",
  retries: 3,
};
// тип: { method: string; retries: number }

Хотя config объявлен через const, поля внутри получили расширенные типы: method стал string, а не "POST". Причина та же изменяемость — сам config переприсвоить нельзя, но его поля менять можно: config.method = "GET" совершенно законно. Поэтому компилятор расширяет типы полей, как расширил бы let-переменную.

Если потом передать этот config туда, где ждут конкретный метод, тип string окажется слишком широким:

function request(method: "GET" | "POST"): void { /* ... */ }

request(config.method);
// ошибка: Argument of type 'string' is not assignable
// to parameter of type '"POST" | "GET"'

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

Чтобы сказать «нет, это значение застыло целиком, вглубь», есть идиома as const — утверждение константности (const assertion):

const config = {
  method: "POST",
  retries: 3,
} as const;
// тип: { readonly method: "POST"; readonly retries: 3 }

request(config.method); // ок: тип config.method — "POST"

as const делает две вещи сразу. Первая — помечает каждое поле readonly, то есть запрещает присваивание: config.method = "GET" теперь не пройдёт проверку. Вторая — раз поля больше нельзя менять, расширять их типы незачем, и компилятор сохраняет самые узкие, литеральные типы: method остаётся "POST", retries — 3. Запрет на изменение покупает точность типа — ровно та же сделка, что между let и const, только проведённая вглубь структуры.

Действие идёт рекурсивно, сквозь вложенность. Массив объектов под as const превращается в readonly-кортеж из readonly-объектов с литеральными полями:

const statuses = ["created", "paid", "shipped"] as const;
// тип: readonly ["created", "paid", "shipped"]

Из такого замороженного массива можно вывести union всех его элементов — и получить тип, который дальше живёт сам по себе:

type Status = (typeof statuses)[number];
// тип: "created" | "paid" | "shipped"

Здесь union — это тип-объединение, «или одно, или другое из перечисленного»; ему посвящена отдельная глава, сейчас достаточно прочитать запись как «Status — это любое из трёх значений». Получилось, что единый список значений в рантайме породил и набор значений, и тип, описывающий этот набор. Не пришлось дублировать перечень в двух местах и следить, чтобы они не разъехались: тип выведен из данных. Это первый намёк на то, ради чего стоит терпеть механику вывода, — но прежде чем развивать его, посмотрим, что вывод умеет ещё.

Вывод внутрь: контекстная типизация

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

Колбэк, переданный в map. По привычке его параметр хочется аннотировать:

const totals = [
  { sku: "A-1", price: 500 },
  { sku: "B-2", price: 300 },
];

const prices = totals.map((item: { sku: string; price: number }) => item.price);

Аннотация item: { sku: string; price: number } здесь — лишняя работа, причём изрядная. Компилятор и так знает тип item, и знает его раньше, чем вы допишете. Уберите аннотацию:

const prices = totals.map((item) => item.price);
// item выведен как { sku: string; price: number }, prices — number[]

Тип item пришёл не из самого item — взять его там неоткуда, у параметра нет инициализатора. Он пришёл снаружи, из контекста вызова. totals — массив объектов { sku: string; price: number }, значит, map вызовет колбэк с элементом именно этого типа. Компилятор смотрит на ожидаемую сигнатуру функции, в которую вы подставляете колбэк, и из неё выводит типы параметров. Это контекстная типизация (contextual typing): тип параметра определяется тем, куда функцию передают, а не тем, что внутри неё написано.

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

Контекст работает всюду, где у выражения есть «ожидаемый тип». Обработчик события получает тип аргумента из типа события:

button.addEventListener("click", (event) => {
  // event выведен как MouseEvent — из того, что слушаем "click"
  console.log(event.clientX);
});

Никто не писал event: MouseEvent. Тип пришёл из строки "click": проверяющий знает, что у addEventListener сигнатура для "click" отдаёт MouseEvent, и протянул этот тип в параметр колбэка. То же происходит, когда переменной заранее задан тип функции, а присваивают ей стрелку:

type Reducer = (acc: number, order: Order) => number;

const sumTotals: Reducer = (acc, order) => acc + order.total;
// acc выведен как number, order — как Order, из типа Reducer

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

Вывод возвращаемого типа — и когда его всё же писать

Функции выводят и возвращаемый тип. Аннотировать его компилятору не нужно — он проследит за return и соберёт тип сам:

function describe(order: Order) {
  return `Заказ ${order.id} на сумму ${order.total}`;
}
// выведенный возвращаемый тип: string

function discounted(order: Order) {
  if (order.total > 10000) return order.total * 0.9;
  return order.total;
}
// выведенный возвращаемый тип: number

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

Возьмём функцию, которая по статусу заказа отдаёт ярлык:

function label(status: Order["status"]): string {
  switch (status) {
    case "created": return "Создан";
    case "paid": return "Оплачен";
    case "shipped": return "Отправлен";
    case "delivered": return "Доставлен";
    // ветку "cancelled" забыли
  }
}
// ошибка: Function lacks ending return statement
// and return type does not include 'undefined'

Аннотация : string поймала дыру: статус "cancelled" не покрыт ни одной веткой, на нём функция дойдёт до конца switch и неявно вернёт undefined, а это не string. Компилятор сообщает об этом здесь, в теле функции, где ошибку и надо чинить. Уберите аннотацию — и ошибка не исчезнет, а переедет: возвращаемый тип выведется как string | undefined, функция пройдёт проверку, а спотыкание случится потом, в какой-то далёкой точке вызова, где на результате попробуют дёрнуть метод строки. Аннотация возврата работает как контракт: она прибивает ошибку к её источнику.

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

function nextStatus(current: "created"): "paid" {
  return current === "created" ? "paid" : "paid";
}

Без : "paid" возвращаемый тип вывелся бы как string, и функция молча отдавала бы наружу слишком широкий тип. С аннотацией компилятор удержит тело в обещанных рамках. Граница здесь та же, что и со всеми аннотациями: возвращаемый тип на короткой внутренней функции — шум, дублирующий очевидное; на экспортируемой функции, на публичном API — контракт, который ловит расхождение локально и не даёт ему утечь к потребителям.

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

Кажется, что всё это знакомо: в Java тоже есть var. С Java 10 можно писать var total = 500; вместо int total = 500;, и тип выведется из инициализатора. Вывод джависту не в новинку — он уже им пользуется.

Но var в Java — узкая форточка, а не образ жизни. Во-первых, он работает только на локальных переменных внутри метода. Поля класса, параметры методов, возвращаемые типы по-прежнему обязаны быть аннотированы — там var запрещён синтаксически. Во-вторых, var не делает того, что делает контекстная типизация: он выводит тип из значения справа, но не умеет вытягивать тип параметра из контекста, куда выражение подставляют, — потому что в Java тип лямбды задаётся целевым функциональным интерфейсом отдельно, а не растекается свободно. В-третьих, и это главное, var total = 500 даёт int, а не литеральный тип 500: у Java литеральных типов нет вовсе, расширять до базового нечего, потому что узкого варианта не существует. Та самая разница const/let, с которой началась глава, в Java непредставима — там нет ни уровня точности, на котором она проявляется, ни механизма, который бы её создавал.

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

Когда вывод подводит

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

Расширение свойств объекта даже под const. Самая частая засада — та, что мелькнула выше. const сохраняет литеральный тип для примитива, но не для поля объекта:

const order = { id: "A-1", status: "created" };
// тип: { id: string; status: string } — не "created"!

function handle(o: { status: "created" | "paid" }): void {}
handle(order);
// ошибка: Types of property 'status' are incompatible.
// Type 'string' is not assignable to type '"created" | "paid"'

Интуиция кричит, что order.status — это "created", ведь так написано. Но order объявлен через const лишь снаружи: переприсвоить order нельзя, а вот order.status = "paid" законно. Раз поле изменяемо, компилятор его расширяет — и status становится string. Лечится точечно: status: "created" через as const на самом поле либо as const на всём объекте, либо явный тип переменной. Главное — не удивляться, что const не уберёг литерал вглубь: он его там и не обещал.

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

function parse(raw: any) {
  return raw.order; // тип: any
}

const o = parse("{}"); // o: any
o.total.whatever.nonsense; // ни одной ошибки — проверки отключены

Тип any означает «проверяющий, отойди», и выведенный any отключает проверку не только на себе, но и на всём, что из него получено. Параметр raw: any заразил возврат, возврат заразил o, и дальше можно писать любую бессмыслицу. Особенно коварен неявный any — тот, что компилятор вывел бы для параметра без аннотации и без контекста, потому что взять тип неоткуда:

function totalOf(order) { // order — неявный any
  return order.total;
}

Без аннотации и без ожидающего контекста order не из чего вывести, и по умолчанию он стал бы any, молча сняв все проверки. Флаг noImplicitAny (входит в strict) запрещает это: компилятор требует либо аннотацию, либо контекст и не даёт any просочиться по умолчанию.

// при strict:
// ошибка: Parameter 'order' implicitly has an 'any' type

Граница тут тонкая и стоит того, чтобы её проговорить. Параметры функции — единственное место, где аннотация не повинность, а необходимость: у них нет инициализатора, и если контекст не задаёт их тип (как задаёт контекст колбэку в map), вывести его не из чего. «Молчи и доверяй выводу» относится к переменным с инициализатором и к колбэкам в типизированном контексте — но не к параметрам обычной функции, объявленной на пустом месте. Их тип — это вход контракта, его описываете вы.

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

const ids = []; // тип: any[] — «развивающийся» массив, тип элемента не зафиксирован
ids.push("A-1");
ids.push(42);   // оба push проходят молча; элемент дорос до string | number

Из пустого литерала вывести тип элемента нечем, и компилятор берёт «развивающийся» (evolving) any[]: он дописывает тип элемента по тому, что в массив кладут, и после двух push элемент становится string | number — ни один из них не ловится. Тут аннотация не роскошь, а способ сказать то, чего во входных данных нет:

const ids: string[] = []; // теперь push(42) — ошибка

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

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

Ни один из этих случаев не довод против вывода. Это карта мест, где молчание стоит нарушить, — и каждое из них объясняется одним правилом: вывод берёт ровно то, что видит в значении, и не догадывается о ваших намерениях. Где намерение шире или уже увиденного, его проговаривают вслух.

Аннотация — это закреплённое утверждение, а не повинность

Вот сдвиг, ради которого вся глава.

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

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

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

Где именно проходит граница между «закрепить» и «довериться» — вопрос не механический. На одном полюсе пуристы аннотируют каждый экспорт ради явных контрактов; на другом — выводят почти всё ради краткости; правда обычно посередине и зависит от того, кто будет читать код и насколько дорого ошибиться. Это уже вопрос зрелого вкуса, и к нему мы вернёмся отдельно (Глава 30). Пока достаточно сменить установку по умолчанию: не «аннотирую всё, молчу в виде исключения», а «молчу по умолчанию, аннотирую прицельно».

Итог

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

← Глава 3. Стирание: почему типов нет в рантайме Содержание Глава 5. Компилятор как доказательство →