Глава 1. Что такое TypeScript на самом деле
Глава 1. Что такое TypeScript на самом деле
Аннотация, которой нет
Вот функция. Она проходит проверку типов без единого замечания, компилятор доволен, редактор подсвечивает всё зелёным.
function getTotal(): number {
return JSON.parse('"500"'); // вернётся строка "500"
}
const total = getTotal(); // тип: number
console.log(total.toFixed(2)); // компилируется без ошибок
Запустите её — и получите TypeError: total.toFixed is not a function. Тип сказал number, в руках оказалась строка, а вызов toFixed рассыпался в рантайме. Компилятор не соврал по злому умыслу: он поверил аннотации : number на возврате JSON.parse, потому что проверять там нечего — JSON.parse по определению возвращает any, а any совместим с чем угодно. Аннотация пообещала число. В рантайме обещание никто не сдержал, и некому было спросить.
Теперь посмотрим, куда эта аннотация девается. Возьмём кусок предметной области, к которой мы будем возвращаться всю книгу, — заказ в интернет-магазине:
// order.ts
type Order = { id: string; total: number };
function describe(order: Order): string {
return `Заказ ${order.id} на сумму ${order.total}`;
}
Пропустим файл через компилятор и посмотрим, что он выдаёт на выходе:
// order.js — то, что реально исполняется
function describe(order) {
return `Заказ ${order.id} на сумму ${order.total}`;
}
Тип Order исчез. Аннотации : Order и : string исчезли. От всей типовой работы в исполняемом файле не осталось ни байта. Не «свернулись в проверку на входе», не «превратились в скрытый assert» — физически испарились. То, что бежит в Node или в браузере, не подозревает, что когда-то было типизировано.
Соедините два наблюдения, и привычная картина мира треснет. Аннотация ничего не делает в рантайме, потому что в рантайме её нет. А раз её нет, она не может ни поймать строку, прикинувшуюся числом, ни остановить программу. Она вообще не охранник. Тогда кто она и зачем — если самый ходовой образ TypeScript, «JavaScript, которому приделали проверки на лету», оказывается ложным с первой же строки?
Две программы в одном файле
Разгадка в том, что один и тот же текст .ts-файла читают два совершенно разных адресата, и читают в разное время.
Первый адресат — проверяющий типов (type checker). Он запускается, когда вы сохраняете файл, когда редактор подсвечивает ошибки, когда вы зовёте tsc. Он видит Order, : number, : string, прослеживает, сходятся ли формы значений, и либо молчит, либо ругается. Для него аннотации — главное содержание файла. Закончив, он уходит и больше не появляется.
Второй адресат — исполняющий движок (V8 в Node и Chrome, JavaScriptCore в Safari). Он запускается потом, отдельным запуском, и читает уже не ваш .ts, а тот выхолощенный .js, где типов нет. Для него аннотаций не существовало никогда. Он видит функции, объекты, строки и числа — и гоняет их по правилам JavaScript, ничего не зная и не желая знать о Order.
Эти двое не встречаются. Проверяющий завершил работу до того, как движок начал свою. Между ними — пропасть во времени и стирание как мост, по которому в одну сторону переходит код, а типы остаются на берегу. Когда вы пишете .ts, вы пишете не одну программу, а две, сросшиеся в одном тексте:
- проверяющую — написанную аннотациями, адресованную type checker’у, исполняемую во время компиляции;
- исполняющую — написанную выражениями и инструкциями, адресованную движку, исполняемую в рантайме.
Почти всё, что новичку в TypeScript кажется странным, нелогичным или непоследовательным, — это место, где он спутал, к какой из двух программ относится строчка кода. Стоит держать обе врозь — и странности выстраиваются в систему.
Что делает tsc: проверить и стереть
У компилятора tsc две работы, и важно, что они отделимы.
Первая — проверка (type checking): прогнать проверяющую программу, свести формы, выдать ошибки. Вторая — стирание и транспиляция (emit): выкинуть все аннотации и переписать оставшийся современный синтаксис в тот диалект JavaScript, который вы попросили в настройках. Стирание — это не «компиляция типов в проверки». Это буквально удаление. Типы не переживают emit; они выбрасываются.
Что эти работы независимы, видно по тому, что каждую можно запустить без другой. Можно проверить, ничего не породив:
tsc --noEmit # только проверка, ни одного .js на выходе
Так делают в CI: нужно знать, сходятся ли типы, а файлы не нужны. И наоборот — можно стереть, ничего не проверяя. Современный Node умеет запускать .ts напрямую, и делает он это самым тупым и честным способом: отрывает типы и исполняет остаток, вообще не проверяя его.
node order.ts # типы отрезаются и выбрасываются, проверки нет
Вернёмся к функции, с которой начали. Под node order.ts она прекрасно запустится, дойдёт до getTotal(), получит строку, споткнётся на toFixed — ровно так же, как если бы вы прошли через tsc. Потому что проверка её никогда и не спасала: она лишь сообщала о возможной беде заранее, отдельной программой, в другое время. Сотрите эту программу — и исполняющая часть останется ровно той же. Это и есть лучшее доказательство, что типы не участвуют в исполнении: их можно срезать, и поведение не изменится ни на йоту.
Проверяющий не исполняет, исполняющий не проверяет
Разделение не половинчатое, а абсолютное, и держится оно на простом факте: типовой слой никогда не доходит до рантайма, а рантайм ничего не знает о типах и знать не может.
В одну сторону: проверяющая программа не исполняет ваш код. Она рассуждает о нём. Когда вы пишете тип, который сам по себе является вычислением, — а в TypeScript типы умеют вычислять, и этому посвящена целая часть книги, — это вычисление происходит в голове у type checker’а во время проверки и не оставляет в выходном .js ни следа. Ни одно значение не «течёт» из типов в рантайм.
В другую сторону: исполняющая программа не может спросить у значения его тип, потому что спрашивать не у кого. Тип — это не свойство, прикреплённое к значению; это утверждение, которое проверяющий держал в уме про переменную. К моменту запуска утверждение стёрто. Поэтому в рантайме нельзя написать «если x имеет тип Order»: спросить не получится, бирки нет.
Это сразу проводит границу между тем, что в рантайме спросить можно, и тем, что нельзя — и граница ложится точно по линии «существует ли это в JavaScript сам по себе».
if (value instanceof Date) { /* работает */ }
if (typeof value === "string") { /* работает */ }
if ("total" in value) { /* работает */ }
Всё это — настоящие операторы JavaScript. instanceof проверяет цепочку прототипов, typeof спрашивает у движка одну из горстки рантайм-категорий, in смотрит, есть ли ключ. Они существуют без всякого TypeScript и переживают стирание, потому что им стирать нечего. А вот это — не работает и работать не может:
if (value instanceof Order) {} // ошибка: 'Order' — это тип, а не значение
Order объявлен через type. После стирания от него ничего не остаётся — нет объекта, у которого instanceof мог бы проверить прототип. Спрашивать не у кого. Та же пропасть объясняет, почему в TypeScript нельзя поймать исключение «определённого типа», как привык кто угодно из статически типизированного мира:
try {
risky();
} catch (e: PaymentError) {} // нельзя: нет типизированного catch
catch — оператор JavaScript, и ему достаётся то, что бросили, без всякой бирки. К моменту, когда исключение летит, типов уже нет; отобрать «только PaymentError» рантайм неспособен, потому что не видит типов вообще. Всё, что остаётся, — поймать что попало и разбираться руками. Это не недосмотр авторов языка. Это прямое, неизбежное следствие того, что проверяющий не исполняет, а исполняющий не проверяет.
Одно ограничение, из которого растёт язык
Тут стоит остановиться и назвать вещь своим именем, потому что это и есть несущая стена всей книги. У TypeScript есть один-единственный фундаментальный факт, принятый осознанно: типы — это стираемый статический слой над неизменным динамическим рантаймом. TypeScript не трогает поведение JavaScript. Не добавляет ни одной проверки, которой не было бы в исходном JS. Накладывает поверх живого языка слой утверждений, проверяет их отдельной программой — и снимает перед запуском, не оставив следа.
Примите это всерьёз — и добрая половина языка перестаёт быть набором разрозненных причуд, а оказывается одним и тем же фактом в разных костюмах:
- Почему типов нет в рантайме. Потому что слой стираемый. Прямое определение. (Глава 3.)
- Почему
instanceofработает, аinstanceof Order— нет. Первое — оператор JS над тем, что есть в рантайме; второе спрашивает про то, чего в рантайме не существует. - Почему
catch (e: T)запрещён. Та же причина: к рантайму бирки стёрты, отбирать не по чему. - Почему на границах нужна валидация. JSON из сети, ответ платёжного шлюза, ввод формы приходят в рантайм без всяких типов. Проверяющая программа к ним не прикасалась — её там уже нет. Доверять аннотации на таком значении — то же, что доверять
getTotalиз начала главы. Кто-то должен проверить форму руками, в рантайме. (Главы 24–26.) - Почему существуют type guards и зачем нужен Zod. Это и есть инструменты, которыми наводят мост через пропасть: проверяют форму в исполняющей программе и сообщают результат проверяющей. (Главы 25–26.)
Ни одно из этих свойств не надо запоминать по отдельности. Это следствия. Установив причину раньше всего остального, мы получаем не список исключений, а оптику, сквозь которую остальной язык виден насквозь.
Глазами джависта
Соблазнительно решить, что всё это знакомо: ведь и в Java две фазы —
javacкомпилирует исходник, JVM его исполняет. Компиляция отдельно, запуск отдельно; в чём разница?Разница в том, что рантайм Java помнит типы, а рантайм JavaScript — нет. JVM при загрузке класса заново проверяет байт-код и держит типовую информацию при себе всю дорогу. Поэтому
obj instanceof Orderв Java — обычное дело: рантайм знает, какого класса объект, бирка пришита к нему намертво.catch (PaymentError e)работает по той же причине — у исключения есть рантайм-тип, и JVM умеет отбирать по нему. В Java типы — это и проверка во время компиляции, и реальность во время исполнения.В TypeScript второй половины нет.
tsc→.js→ V8: на последнем шаге типы стёрты, и V8 не восстанавливает их и не проверяет — он работает с голым JavaScript. Вот почему интуиция джависта «тип реален в рантайме» переносится сюда неверно и ломается раньше любой другой:instanceof Orderи типизированныйcatch, в Java рутинные, здесь невозможны в принципе.И есть одна трещина, общая для обоих языков, — она пригодится дальше как мост, а не как контраст. Дженерики Java тоже стираются:
List<String>иList<Integer>в рантайме — один и тот жеList, параметр типа выброшен. То есть стирание джависту не чуждо; он уже живёт с ним, просто в одном углу языка. TypeScript всего лишь распространил знакомый принцип на все типы, а не на одни дженерики. Это не диковинный изъян TS — это доведённое до предела свойство, кусочек которого есть и в Java.
Когда тип лжёт
Раз проверяющая и исполняющая программы — это две разные программы, между ними возможен разрыв. «Прошло проверку» и «верно в рантайме» — не одно и то же утверждение, и второе из первого не следует. С этим разрывом надо познакомиться сразу, без иллюзий, потому что управление им — сюжет всей оставшейся книги.
Чаще всего разрыв пробивают намеренно, двумя инструментами. Первый — any, тип-капитуляция, означающий «проверяющий, отойди, тут я отвечаю». Второй — as, утверждение типа (type assertion), означающее «я знаю лучше тебя, считай, что здесь вот это». Оба не выполняют никакой проверки в рантайме — они лишь меняют то, во что верит проверяющая программа.
const raw: unknown = JSON.parse('{"id":"A-1"}'); // total отсутствует
const order = raw as Order; // «поверь, это Order»
console.log(order.total.toFixed(2));
// проверка пройдена. рантайм: Cannot read properties of undefined
as Order ничего не проверил. Он переписал убеждение type checker’а — и order.total стало, с точки зрения проверяющего, числом, хотя в пришедшем объекте поля total нет вовсе. Проверяющая программа спокойна. Исполняющая падает на undefined. Между ними — ровно тот разрыв.
Назвать это «багом языка» было бы непониманием. Это несостоятельность по дизайну (unsoundness): TypeScript сознательно соглашается иногда пропустить неверную программу, чтобы взамен суметь типизировать живой, чужой, динамический JavaScript, который иначе не типизируется вовсе. Это осознанный размен, и мы будем разбирать его цену не раз. Сейчас важно одно: компилятор — не оракул истины. Он проверяет утверждения, которые вы ему дали, и ровно настолько, насколько вы дали ему честные утверждения. Зелёная сборка означает «противоречий не нашлось», а не «программа верна». Вся остальная книга — про то, где проходит эта разница и как держать её под контролем.
Тип — это стираемое утверждение, а не страж
Вот переключатель, который должен щёлкнуть, и после него остальная книга читается иначе.
Перестаньте видеть в аннотации рантайм-охранника, который где-то внутри стоит на воротах и заворачивает плохие значения. Его там нет — и в скомпилированном файле вы убедились в этом своими глазами. Аннотация — это утверждение, которое вы делаете про значение, адресованное отдельной программе-проверяющему, и стираемое до запуска. Вы не ставите защиту. Вы выдвигаете тезис и отдаёте его на проверку.
Из этого вытекает другой способ работать. Писать на TypeScript — значит писать два слоя сразу: исполняющий код и доказательство о нём. Доказательство проверяет другая программа, в другое время, и затем выбрасывает его. Сила этой проверки целиком в честности ваших утверждений: соврёте проверяющему через any или as — и он добросовестно подтвердит ложь, как подтвердил getTotal(): number. А там, где гарантия проверяющего не дотягивается, — на границе рантайма, на чужих данных, на стыке с нетипизированным миром, — ответственность ваша, и закрывается она не аннотацией, а настоящей проверкой в исполняющей программе. Тип говорит, чему вы хотите верить. Истинно это сделать — отдельная работа, и вы теперь знаете, почему она ваша.
Итог
В одном .ts-файле живут две программы — проверяющая и исполняющая, — связанные стиранием: первая рассуждает о типах во время компиляции, вторая работает с голым JavaScript, и типы между ними не переходят. Запомните этот единственный факт — и три четверти «странностей» языка перестанут быть странностями и окажутся его следствиями.