TypeScript и JavaScript: в чём разница
Зачем это нужно
В прошлых главах сеть уже поднята, нода отвечает, а контракт написан на TypeScript и приехал в докер-образе. Ровно в этот момент почти у каждого возникает вопрос, который стесняются задать вслух: а TypeScript — это вообще что? Отдельный язык? Диалект JavaScript? Почему в проекте лежат и .ts-файлы, и tsconfig.json, и папка dist с почти такими же файлами, только без типов? И почему нода, которая исполняет контракт, про TypeScript ничего не знает?
Ответ на всё это короткий, и с него честнее начать: TypeScript — это JavaScript плюс проверка, которая выполняется до запуска и исчезает после сборки. Никакого второго языка внутри контейнера нет. Есть один язык, который умеет исполнять нода, и есть надстройка, которая до этого исполнения успевает задать тебе неудобные вопросы.
Эта глава — про то, что именно делает эта надстройка, что она успевает поймать, что не поймает никогда, и как всё это выглядит на коде из твоей же работы: контракт, скрипт к ноде, ответ REST-запроса на порт 6862. Ни одной абстрактной задачи про Animal и Dog здесь не будет.
- Зачем это нужно
- JavaScript: язык, который действительно выполняется
- Что такое компиляция в JS и почему в ноде нет никакого TS
- Примитивы и вывод типов
- Union: «одно из перечисленного»
- Интерфейсы: описание формы объекта
- Дженерики: тип как параметр
any: почему он выключает весь смысл- Строгий режим: чем
strictотличается от «просто TypeScript» - Типизация ответа REST-запроса к ноде
- Что ловит TypeScript и что не ловит никто
tsconfig.json: четыре настройки, которые реально включают- Как читать чужой
.d.ts - Когда честно хватает обычного JavaScript
- Практика: набери руками
- Шпаргалка: что где живёт
- На чемпионате
- Что должен уметь
- Куда дальше: проверенные ресурсы
JavaScript: язык, который действительно выполняется
Начинать надо не с TypeScript, а с того, поверх чего он живёт.
JavaScript — язык, у которого есть исполнитель. Исполнитель называется движком: V8 в Chrome и в Node.js, SpiderMonkey в Firefox, JavaScriptCore в Safari. Движок принимает текст программы и выполняет его. Больше в этой цепочке никого нет: когда контракт на ноде обрабатывает вызов, работает движок, который читает JavaScript.
Два свойства JavaScript, из которых растёт весь смысл TypeScript.
Свойство первое: типы у значений есть, а у переменных — нет. Значение 42 — число, значение '3PJa...' — строка, это правда и в JavaScript. Но переменная не привязана ни к какому типу: сегодня в ней число, через строчку — строка, ещё через строчку — объект. Язык не возражает.
let height = 152340; // число
height = 'сто пятьдесят'; // строка — и это тоже нормально
height = null; // и так тоже
Свойство второе: несовпадение типов почти никогда не останавливает программу. Вместо ошибки JavaScript старается что-нибудь придумать:
const fee = '0.05';
const total = fee * 100; // 5 — строка сама превратилась в число
const label = fee + 100; // '0.05100' — а здесь число превратилось в строку
const missing = undefined + 1; // NaN — «не число», и программа поехала дальше
Все три строки выполняются без единой жалобы. И вот это — главная беда: программа не падает там, где сделана ошибка. Она падает через пять функций, когда NaN доезжает до места, где его уже никак не проглотить, — или, что хуже, не падает вовсе и просто пишет неверное значение в блокчейн.
У ошибок в JavaScript есть неприятная особенность: место возникновения и место проявления почти никогда не совпадают. Строка amount * fee, где fee случайно оказался строкой, отработает молча. NaN поедет дальше, попадёт в тело транзакции, транзакция уедет на ноду, нода её примет — и ошибку ты увидишь в виде странного числа в состоянии контракта через час. TypeScript ценен не тем, что «показывает типы», а тем, что делает место ошибки и место жалобы одним и тем же местом.
Что такое компиляция в JS и почему в ноде нет никакого TS
Слово «компиляция» в мире JavaScript означает не совсем то, к чему приучают на паре по C++. Там компиляция — перевод исходника в машинный код. Здесь — перевод одного текста программы в другой текст программы, обычно из более удобного человеку в более понятный движку. Такой перевод часто называют транспиляцией, но на практике слова используют вперемешку.
TypeScript-компилятор tsc делает ровно две разные вещи за один проход, и их важно не путать:
- Проверяет типы. Читает твои аннотации, сверяет их со всем остальным кодом и выдаёт список несоответствий.
- Стирает типы. Убирает из текста всё, чего в JavaScript не существует: аннотации, интерфейсы, псевдонимы типов, дженерики — и сохраняет получившийся JavaScript в файл.
Второй пункт называется type erasure — стирание типов. Он и объясняет, почему на ноде «нет никакого TypeScript».
// contract.ts — то, что пишешь ты
interface Transfer {
recipient: string;
amount: number;
}
export function makeTransfer(t: Transfer): string {
return `${t.recipient}:${t.amount}`;
}
// dist/contract.js — то, что получилось после tsc и что выполняет нода
export function makeTransfer(t) {
return `${t.recipient}:${t.amount}`;
}
Интерфейс Transfer исчез целиком. Не «превратился в проверку», не «остался где-то в метаданных» — просто исчез, потому что в JavaScript такой конструкции нет. Аннотация : Transfer тоже исчезла. Осталась ровно та функция, которую ты бы написал на JavaScript руками.
Отсюда три следствия, которые стоит запомнить дословно.
Следствие первое: типы не существуют во время выполнения. Нельзя спросить у объекта, соответствует ли он интерфейсу Transfer, — интерфейса в рантайме нет. Проверять форму данных приходится обычным JavaScript-кодом: typeof, Array.isArray, наличие поля в объекте.
Следствие второе: TypeScript не делает программу быстрее и не делает её медленнее. На выходе тот же самый JavaScript. Нет ни ускорения от «строгой типизации», ни накладных расходов на проверки — проверки уже закончились, когда сборка завершилась.
Следствие третье: ошибка типов не мешает получить JavaScript. По умолчанию tsc ругается, но файлы всё равно пишет. Именно поэтому в проекте с «красными» типами сборка может выглядеть успешной, а на ноду уехать заведомо кривой код.
Node.js с версии 22 умеет запускать .ts-файлы напрямую — но делает он это ровно тем же способом, что и tsc: стирает типы и исполняет получившийся JavaScript, ничего не проверяя. Это удобно для запуска скрипта одной командой и абсолютно бесполезно как проверка: файл с грубейшей ошибкой типов запустится молча. Проверку по-прежнему делает отдельный запуск tsc --noEmit — руками, в скрипте npm run typecheck или в CI.
Практический вывод для проекта с контрактом: в репозитории всегда есть две разные команды, и путать их нельзя. tsc --noEmit — «проверь и ничего не собирай». tsc или npm run build — «собери JavaScript, который поедет в докер-образ». Первая ловит ошибки, вторая делает артефакт.
Примитивы и вывод типов
Первый и самый частый вид аннотации — тип примитивного значения. Их всего несколько, и они соответствуют тому, что и так есть в JavaScript.
| Тип | Что означает | Пример из работы с нодой |
|---|---|---|
string | строка | адрес, идентификатор транзакции, base58-строка |
number | число, любое: и целое, и дробное | высота блока, комиссия, timestamp |
boolean | правда или ложь | флаг «контракт подтверждён» |
bigint | целое произвольной длины | суммы в минимальных единицах, где number уже опасен |
null и undefined | «значения нет» — два разных способа | поле отсутствует в ответе ноды |
Аннотация пишется после имени через двоеточие:
const nodeUrl: string = 'http://localhost:6862';
const height: number = 152340;
const isMining: boolean = true;
Но в реальном коде так почти не пишут — и это не небрежность, а норма. TypeScript выводит тип сам, если может:
const nodeUrl = 'http://localhost:6862'; // TypeScript и так знает: string
let height = 152340; // и здесь: number
height = 'ещё немного';
// ^ Type 'string' is not assignable to type 'number'.
Вывод типов (type inference) — причина, по которой TypeScript-код почти не отличается на вид от JavaScript. Аннотации ставят там, где вывести неоткуда: аргументы функций и границы с внешним миром — ответы сети, чтение файлов, входные данные контракта.
Отдельно про const и let. В TypeScript у них разное поведение при выводе:
const status = 'succeeded'; // тип — литерал 'succeeded', уже ничего другого туда не положить
let state = 'succeeded'; // тип — string, можно присвоить любую другую строку
Это не придирка компилятора, а полезное свойство: константы становятся более точными типами сами собой, и на них потом опирается сужение union.
Union: «одно из перечисленного»
Дальше начинается то, ради чего TypeScript и берут. Union-тип (объединение) описывается вертикальной чертой и означает «значение — одно из этих».
Самый живой пример из работы с нодой: статус транзакции. У узла Waves Enterprise транзакция может быть в одном из трёх состояний, и они известны заранее:
type TxStatus = 'unknown' | 'unconfirmed' | 'confirmed';
function describe(status: TxStatus): string {
if (status === 'unknown') return 'нода про транзакцию не слышала';
if (status === 'unconfirmed') return 'лежит в пуле, ждёт блока';
return 'в блоке';
}
describe('confirmed'); // ок
describe('succeded'); // ошибка ещё до запуска: такого варианта нет
Опечатка succeded вместо succeeded — классика, которая в JavaScript живёт до продакшена и тихо уводит выполнение в else. В TypeScript она красная в редакторе.
Сужение: как компилятор следит за проверками
Union полезен не сам по себе, а вместе с сужением (narrowing). Компилятор читает твои проверки и понимает, что внутри ветки тип стал уже:
function heightOrMessage(value: number | string): string {
if (typeof value === 'number') {
return `высота ${value.toFixed(0)}`; // здесь value точно number
}
return value.trim(); // а здесь точно string
}
Никакой магии: компилятор просто умеет читать typeof, ===, in, Array.isArray и instanceof так же, как их читает человек. Обратная сторона — если проверки нет, обращение к методу конкретного типа не пройдёт:
function bad(value: number | string) {
return value.toFixed(0);
// ^ Property 'toFixed' does not exist on type 'string | number'.
}
Это ровно тот случай, когда компилятор ловит настоящую ошибку: строка действительно не умеет toFixed, и в JavaScript этот код упал бы в рантайме на первом же вызове со строкой.
Необязательное поле и «значения нет»
Ответ ноды часто содержит поля, которых может не быть. Это тоже union — с undefined:
interface TxInfo {
id: string;
height?: number; // вопросительный знак: поле может отсутствовать
}
function show(tx: TxInfo): string {
if (tx.height === undefined) return 'ещё не в блоке';
return `блок ${tx.height}`;
}
Без проверки tx.height имеет тип number | undefined, и арифметика с ним не пройдёт — компилятор напомнит именно про тот случай, который на практике и ломает скрипты: транзакция ещё не попала в блок.
Интерфейсы: описание формы объекта
Интерфейс — способ один раз описать форму объекта и дальше пользоваться именем. Для скриптов к ноде это основной инструмент: почти всё, что приезжает и уезжает, — объекты с известными полями.
interface NodeStatus {
blockchainHeight: number;
stateHeight: number;
updatedTimestamp: number;
updatedDate: string;
}
function isSynced(status: NodeStatus): boolean {
return status.blockchainHeight === status.stateHeight;
}
Что даёт это описание на практике, помимо ловли опечаток:
- Автодополнение. Набрал
status.— редактор показывает четыре поля и их типы. Это быстрее, чем открывать документацию, и надёжнее, чем помнить. - Документация, которая не устаревает молча. Интерфейс лежит рядом с кодом; если ответ ноды изменился, а интерфейс нет — рассинхрон вылезет в первом же месте использования, а не в проде.
- Безопасный рефакторинг. Переименовал поле в интерфейсе — компилятор перечислил все места, где оно используется.
interface и type: что выбрать
TypeScript даёт два способа назвать тип. Для описания объекта они почти взаимозаменяемы:
interface Transfer {
recipient: string;
amount: number;
}
type TransferAlias = {
recipient: string;
amount: number;
};
Практическое правило простое: форма объекта — interface, всё остальное — type. Union, литеральные типы, тип-функция и типы, собранные из других типов, пишутся через type, потому что interface так не умеет. Спорить об этом дольше пятнадцати секунд не стоит: в чужом проекте пиши, как принято вокруг.
Вложенные структуры
Ответы ноды почти всегда вложенные, и интерфейсы просто складываются друг в друга:
interface ContractCall {
contractId: string;
params: ContractParam[];
}
interface ContractParam {
key: string;
type: 'string' | 'integer' | 'boolean' | 'binary';
value: string;
}
Обрати внимание на поле type: это union из четырёх литералов. В коде вызова контракта это самая полезная строчка во всём файле — она превращает «надо посмотреть, какие бывают типы параметров» в подсказку редактора.
Интерфейс для ответа ноды удобнее всего писать не из головы, а из живого ответа. Дёрни ручку curl -s http://localhost:6862/node/status, скопируй JSON и опиши ровно те поля, которые собираешься использовать. Описывать весь ответ целиком не нужно и вредно: чем меньше полей в интерфейсе, тем меньше он врёт, когда версия ноды поменяется.
Дженерики: тип как параметр
Дженерики (generics) звучат страшно, а нужны для одной простой вещи: чтобы функция или тип работали с разными типами, не теряя знания о том, с каким именно типом работают сейчас.
Начнём с проблемы. Ты пишешь обёртку над запросами к ноде — одну на весь скрипт:
async function get(path: string): Promise<unknown> {
const res = await fetch(`http://localhost:6862${path}`);
return res.json();
}
Работает, но результат всегда unknown: каждое место вызова обязано разбираться само. Ставить вместо unknown тип NodeStatus нельзя — по этому же пути ходят и другие ручки. Вот здесь и появляется параметр типа:
async function get<T>(path: string): Promise<T> {
const res = await fetch(`http://localhost:6862${path}`);
return (await res.json()) as T;
}
const status = await get<NodeStatus>('/node/status');
// status.blockchainHeight — редактор знает про поле
const height = await get<BlockHeight>('/blocks/height');
// height.height — а здесь знает про другое
T — не конкретный тип, а место, куда подставят тип при вызове. Одна функция, разные результаты, полное знание о результате в каждом месте вызова.
Дженерики уже встречаются в коде, который ты писал, даже если ты их не замечал:
Promise<NodeStatus>— обещание, что когда-нибудь будетNodeStatus.Array<string>(то же самое, чтоstring[]) — массив строк.Record<string, number>— объект, где ключи строки, а значения числа.Map<string, ContractParam>— словарь параметров контракта по ключу.
Ограничение параметра типа
Иногда от подставляемого типа что-то требуется. Например, функция логирования результата вызова контракта хочет, чтобы у объекта было поле contractId:
function logCall<T extends { contractId: string }>(call: T): T {
console.log('вызов контракта', call.contractId);
return call;
}
extends здесь не про наследование классов, а про требование: «подставляй любой тип, лишь бы у него было поле contractId типа string». Внутри функции компилятор разрешает обращаться к этому полю, а на выходе возвращает исходный тип, а не урезанный, — вызывающий код ничего не теряет.
Дженерик в примере с get<T> честно ставит as T — это утверждение типа, а не проверка. Компилятор поверит на слово, а нода про твоё обещание не знает: если ручка вернёт другой JSON, ошибки не будет ни при сборке, ни при вызове get, — она проявится позже и в другом месте. Дженерик здесь удобство для редактора, а не гарантия. Гарантию даёт только проверка данных в коде — про неё раздел про ответ REST-запроса ниже.
any: почему он выключает весь смысл
any — тип «мне всё равно». Значение с типом any можно присвоить куда угодно, вызвать у него любой метод, обратиться к любому полю. Компилятор молчит на всё.
const data: any = await res.json();
data.hieght + 1; // опечатка в имени поля — молчание
data.foo.bar.baz(); // трёх этих полей не существует — молчание
const n: number = data; // присвоили что угодно числу — молчание
Каждая из этих строк упадёт в рантайме или испортит данные. Ни одна не будет подсвечена.
Хуже того, any заразен. Присвоил any в переменную — она тоже any. Передал в функцию, вернул из функции, положил в поле объекта — проверка перестаёт работать по всей цепочке. Один торопливый any на границе с сетью способен обнулить типизацию половины скрипта, и внешне это никак не видно: код выглядит типизированным.
Чем заменить: unknown
Для той же задачи «я пока не знаю, что здесь» есть честный тип — unknown. Он означает «значение есть, но пользоваться им нельзя, пока не проверишь»:
const data: unknown = await res.json();
// data.height; // ошибка: 'data' is of type 'unknown'
if (typeof data === 'object' && data !== null && 'height' in data) {
const height = (data as { height: unknown }).height;
if (typeof height === 'number') {
console.log('высота', height + 1); // здесь уже честно number
}
}
Выглядит длиннее — и это ровно та длина, которую в JavaScript ты не написал бы вовсе, а потом полдня искал бы, откуда взялся NaN. Разница между any и unknown в одной фразе: any говорит компилятору «не мешай», unknown говорит «напомни мне проверить».
Где any всё-таки допустим
Честности ради: any не запрещён. Он уместен в двух случаях. Первый — временная заглушка при переносе старого JavaScript-кода на TypeScript, когда типы описывают постепенно. Второй — прослойка к библиотеке без типов, у которой ты руками описываешь только нужную часть. В обоих случаях any должен быть локальным и заметным: одна переменная, комментарий рядом, задача в бэклоге. any, который расползся по модулю, — уже не решение, а способ не пользоваться тем, ради чего взяли TypeScript.
Строгий режим: чем strict отличается от «просто TypeScript»
Есть TypeScript, который почти ничего не проверяет, и TypeScript, ради которого его берут. Разница — одна строка в tsconfig.json.
Флаг strict включает сразу набор проверок. Три из них меняют жизнь заметнее всего.
strictNullChecks. Без него null и undefined можно положить в любой тип, и обращение к полю у пустого значения компилятор пропустит. С ним — null становится отдельным вариантом, и его надо обработать:
function firstParam(call: ContractCall): string {
const p = call.params[0];
return p.value;
// ^ без проверки на пустой массив это падение в рантайме
}
noImplicitAny. Без него параметр без аннотации молча получает тип any — то есть половина функций проекта оказывается непроверенной, и никто об этом не знает. С ним компилятор требует либо аннотацию, либо явный any, который хотя бы видно глазами.
strictFunctionTypes и родственники — набор более тонких правил про совместимость функций. Их редко разбирают отдельно; достаточно знать, что они входят в strict и что отключать их поодиночке в учебном проекте незачем.
Практическое правило: новый проект начинается со strict: true. Включить строгость в начале — ноль работы. Включить её в проекте на три тысячи строк — день разбора ошибок, часть из которых окажется настоящими багами (и это, кстати, лучший аргумент в пользу флага).
Когда в шаблоне проекта пишут "strict": true, это разворачивается примерно в восемь отдельных флагов, включая strictNullChecks и noImplicitAny. Отдельные флаги существуют ради постепенного перевода старых проектов: команда включает по одному и чинит ошибки порциями. В новом проекте перечислять их поштучно смысла нет — одна строка делает то же самое и не устареет, когда в очередной версии TypeScript в набор добавят ещё один флаг.
Типизация ответа REST-запроса к ноде
Теперь соберём всё вместе на самой частой задаче трека: скрипт спрашивает у ноды высоту блокчейна по HTTP и что-то с ней делает.
Начинаем с JavaScript-версии, которую пишут все:
const res = await fetch('http://localhost:6862/blocks/height');
const data = await res.json();
console.log(data.height + 1);
Здесь три невидимых допущения: что ответ вообще пришёл, что это JSON, и что в нём есть поле height с числом. Ни одно не проверено.
Версия на TypeScript, шаг за шагом.
interface BlockHeight {
height: number;
}
async function fetchHeight(nodeUrl: string): Promise<number> {
const res = await fetch(`${nodeUrl}/blocks/height`);
if (!res.ok) {
throw new Error(`нода ответила ${res.status}`);
}
const data: unknown = await res.json();
if (typeof data !== 'object' || data === null || !('height' in data)) {
throw new Error('в ответе нет поля height');
}
const height = (data as BlockHeight).height;
if (typeof height !== 'number') {
throw new Error('height не число');
}
return height;
}
Что здесь сделано и зачем каждая часть.
Promise<number>в сигнатуре — обещание вызывающему коду: получишь число, а не «что-то». Всё, что не число, до вызывающего кода не доедет, потому что превратится в исключение.- Проверка
res.ok— та самая, которую забывают всегда.fetchне считает ответ404ошибкой: промис успешно разрешается, аres.json()дальше падает на разборе HTML-страницы с ошибкой. Типы этого не поймают — это не ошибка типов. data: unknown— честная граница. Компилятор не даст обратиться кdata.height, пока не проверишь, и это именно то поведение, которое нужно на входе из сети.- Проверка формы — четыре условия подряд: объект, не
null, поле есть, поле число. Скучно, но это и есть перевод обещания в факт. as BlockHeight— утверждение типа. После проверок оно оправдано; без проверок это было бы просто вранье компилятору.
as не преобразует данные и ничего не проверяет — это фраза «поверь мне». Она компилируется в пустоту, как и все остальные типы. Строчка const status = (await res.json()) as NodeStatus; без единой проверки выглядит типизированной, а по надёжности не отличается от any. Отличать нужно два разных действия: описать ожидаемую форму (интерфейс) и убедиться, что данные ей соответствуют (код с typeof). Первое без второго — документация, а не защита.
Отдельно про то, где границ типизации особенно много в блокчейн-скриптах. Их три, и все они выглядят одинаково безобидно:
- Ответы REST API ноды — разобраны выше.
- Чтение конфигов и переменных окружения.
process.env.NODE_URLимеет типstring | undefined, потому что переменной может не быть. Строгий режим заставит это обработать — и правильно сделает: половина «у меня не запускается» именно про незаданную переменную. - Данные, приходящие в контракт из транзакции. Параметры вызова приезжают строками с указанием типа, и превращение строки в число — ручная операция, которую типы не сделают за тебя.
Что ловит TypeScript и что не ловит никто
Честный разговор о границах — самая полезная часть главы, потому что завышенные ожидания от типов приводят к худшему результату, чем их отсутствие: человек перестаёт проверять то, что типы никогда и не проверяли.
Что TypeScript ловит до запуска:
- Опечатки в именах полей и методов —
tx.snderвместоtx.sender. - Несовпадение типов при вызове: строка туда, где ждут число.
- Обращение к полю, которого в типе нет.
- Забытый
await: попытка сложитьPromise<number>с числом — ошибка. - Необработанный вариант union — если писать функции так, чтобы компилятор мог это заметить.
- Возможный
nullиundefined— при включённомstrictNullChecks. - Последствия рефакторинга: переименовал поле — увидел все места, где о нём знали.
Чего TypeScript не ловит никогда:
- Неверные данные из сети. Нода вернула не то, что обещал интерфейс, — компилятор об этом не узнает, он читал твой код, а не трафик.
- Логические ошибки. Формула комиссии умножает вместо деления; типы совпали идеально, результат неверный.
- Перепутанные значения одного типа. Адрес отправителя и адрес получателя — обе строки. Поменял местами — тишина. Это, пожалуй, самый неприятный класс ошибок в блокчейн-коде.
- Всё, что происходит в рантайме. Сеть отвалилась, диск полон, нода не поднялась, кончился таймаут.
- Ошибки конфигурации. Скрипт отработал безупречно и отправил транзакцию не в ту сеть.
Из этого следует правило, которое стоит держать в голове всю дорогу: зелёная сборка означает «код согласован сам с собой», а не «код правильный». Типы убирают целый класс глупых ошибок и освобождают внимание для настоящих. Заменить проверку данных, тесты и здравый смысл они не могут.
Против перепутанных адресов есть приём, который стоит одной строки и хорошо смотрится в блокчейн-коде: сделать разные типы из одинаковых строк. Если к строке-адресу приписать невидимую метку — отдельный тип для отправителя и отдельный для получателя, — компилятор перестанет пускать одно на место другого, хотя в рантайме это по-прежнему обычные строки. Приём называется брендированными типами; в учебном проекте он избыточен, но знать о нём полезно — он показывает, что «одинаковый тип» и «одинаковый смысл» это разные вещи.
tsconfig.json: четыре настройки, которые реально включают
tsconfig.json в корне проекта делает две вещи сразу: говорит компилятору, как собирать, и самим своим присутствием отмечает корень проекта. В нём десятки опций, но в учебном и чемпионатном проекте настраивают примерно четыре.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
target — в какую версию JavaScript компилировать. Чем ниже значение, тем более старым движкам подойдёт результат и тем больше современных конструкций компилятор перепишет вручную. Для кода, который исполняется на известной версии Node внутри контейнера, занижать target незачем: ES2022 — разумная точка.
module — как оформлять импорты в выходном файле: старый require (CommonJS) или современный import (ES-модули). Самая частая причина сообщения «Cannot use import statement outside a module» — рассинхрон между этой опцией и полем "type" в package.json. Значение NodeNext разбирается с этим само, опираясь на package.json.
strict — разобран в отдельном разделе. Одна строка, ради которой всё и затевалось.
outDir и rootDir — куда класть результат и где лежат исходники. Пара, которая делает проект понятным: src — то, что пишет человек, dist — то, что уезжает в докер-образ. И, кстати, dist — первый кандидат в .gitignore: это результат сборки, он воспроизводится из исходников.
Две оставшиеся строки — из категории «стоит в каждом шаблоне». esModuleInterop чинит импорт старых CommonJS-библиотек через import. skipLibCheck отключает проверку типов внутри .d.ts-файлов зависимостей: чужие ошибки типов не должны ломать твою сборку, а времени эта проверка отнимает много.
Поле include за пределами compilerOptions отвечает на вопрос, какие файлы вообще компилировать. Без него компилятор возьмёт всё, до чего дотянется, включая случайный .ts-файл в папке с черновиками.
Полный список опций с объяснением каждой лежит в справочнике по tsconfig на официальном сайте — это тот случай, когда документация читается лучше любого пересказа: у каждой опции есть значение по умолчанию, версия появления и пример. Держи страницу открытой, когда разбираешься с чужим конфигом, вместо того чтобы копировать непонятные строки из чужого проекта.
Как читать чужой .d.ts
Рано или поздно редактор по нажатию «перейти к определению» откроет файл с расширением .d.ts — и там будет код без единого тела функции. Это нормально: .d.ts — файл объявлений (declaration file), в котором есть только типы и нет реализации.
Зачем он нужен: библиотека распространяется как JavaScript, а типы в JavaScript не живут. Файл объявлений едет рядом и рассказывает компилятору, что у этой библиотеки есть и какой формы.
Так выглядит фрагмент объявлений в духе тех, что лежат в SDK для работы с контрактами:
export declare class ContractClient {
constructor(config: ClientConfig);
call(contractId: string, params: ContractParam[]): Promise<CallResult>;
getState(contractId: string): Promise<Record<string, string>>;
}
export interface ClientConfig {
nodeUrl: string;
apiKey?: string;
timeout?: number;
}
export type CallResult = { txId: string; height?: number };
Как это читать, если видишь такое впервые:
declare— «это существует где-то в JavaScript, тела здесь не будет». Ключевое слово, по которому файл объявлений и узнаётся.- Смотри на сигнатуры, а не на реализацию.
call(contractId: string, params: ContractParam[]): Promise<CallResult>отвечает сразу на три вопроса: что передать, в каком порядке и что вернётся. - Вопросительный знак — необязательный параметр или поле. В
ClientConfigобязателен толькоnodeUrl;apiKeyиtimeoutможно не передавать. Promise<...>в возврате означаетawait. Если этого не сделать, дальше по коду поедет объект промиса, и компилятор об этом скажет.- Иди по ссылкам вглубь. Незнакомый
ContractParam— «перейти к определению» ещё раз; через два-три прыжка упрёшься в примитивы.
Практическая ценность привычки: .d.ts — это документация, которая гарантированно совпадает с кодом. README библиотеки может отстать от реальности на три версии, а файл объявлений едет из той же сборки, что и сам код. Когда документация и .d.ts расходятся, прав .d.ts.
Если библиотека своих типов не привозит, редактор скажет «Could not find a declaration file». Обычно это чинится установкой отдельного пакета с типами из общего репозитория DefinitelyTyped — они называются по шаблону @types/имя-библиотеки. Если и его нет, остаётся описать нужный кусок самому — маленьким .d.ts в своём проекте, ровно на те функции, которыми пользуешься.
Когда честно хватает обычного JavaScript
Хорошая глава про TypeScript обязана содержать раздел про то, когда его не надо брать. Иначе получается не знание, а вера.
Обычный JavaScript уместен, когда:
- Скрипт на тридцать строк и один запуск. Дёрнуть ручку ноды, посмотреть ответ, забыть. Настройка сборки займёт больше времени, чем сам скрипт.
- Никто, кроме тебя, этот код не откроет, и открывать его через месяц ты не собираешься.
- Ты пробуешь идею. На этапе «а сработает ли вообще» типы мешают: форма данных меняется каждые пять минут, и переписывать интерфейсы приходится чаще, чем логику.
- Среда не даёт собирать. Однострочник в консоли браузера, поле для скрипта в чужом инструменте,
node -eв терминале.
TypeScript начинает окупаться там, где появляется хотя бы одно из трёх: чужие данные, чужие люди, время. Ответ ноды — чужие данные. Командный проект — чужие люди. Код, к которому вернёшься через месяц, — время: через месяц ты сам себе чужой человек, и подсказки редактора будут отвечать на вопросы, ответы на которые уже выветрились.
Есть и промежуточный вариант, о котором мало кто знает: типы можно получить, не переходя на TypeScript. Аннотация типов в JSDoc-комментариях внутри .js-файла понимается тем же компилятором, и проверку можно включить одной строкой:
// @ts-check
/**
* @param {string} nodeUrl
* @returns {Promise<number>}
*/
async function fetchHeight(nodeUrl) {
const res = await fetch(`${nodeUrl}/blocks/height`);
const data = await res.json();
return data.height;
}
Файл остаётся обычным JavaScript и запускается без всякой сборки, но редактор проверяет типы и подсказывает. Разумный компромисс для маленьких утилит и для старого проекта, который переводить целиком некогда.
Ну и то, что стоит сказать прямо: для контрактов на треке выбор уже сделан за тебя. SDK привозит типы, шаблоны проектов идут с tsconfig.json, примеры написаны на TypeScript. Спорить с этим на чемпионате — тратить время на переписывание вместо решения задачи.
Практика: набери руками
Ниже два пула строк вперемешку: слева то, что одинаково выглядит и в JavaScript, и в TypeScript, справа — то, что существует только в TypeScript и исчезнет после сборки. Смысл упражнения не только в скорости: пока набираешь, обрати внимание, какие строки уцелеют в dist, а какие компилятор вычеркнет.
Цель: скорость ≥ 110 зн/мин, точность ≥ 90%
const res = await fetch('http://localhost:6862/blocks/height');
Шпаргалка: что где живёт
| Вопрос | JavaScript | TypeScript |
|---|---|---|
| Кто исполняет | движок: V8 в Node.js и Chrome | никто: сначала компиляция в JavaScript |
| Когда видна ошибка типов | во время выполнения, если вообще | до запуска, в редакторе и при сборке |
| Есть ли типы в рантайме | у значений да, у переменных нет | нет вовсе: аннотации стёрты |
| Что даёт редактору | догадки по коду | точное автодополнение и переименование |
| Цена входа | ноль | tsconfig.json и шаг сборки |
| Проверка формы данных из сети | пишется руками | пишется руками — типы её не заменяют |
| Команда проверки | нет | tsc --noEmit |
| Что уезжает в докер-образ | твой код | содержимое dist после tsc |
На чемпионате
Как это выглядит в условиях, где время ограничено, а работу оценивают по результату.
- Красные подчёркивания в редакторе — не украшение, а сэкономленные минуты. Каждая пойманная опечатка в имени поля — это не потерянный прогон скрипта против ноды, который занимает минуту-другую вместе с ожиданием блока.
- Сборка должна проходить. Проект, который не собирается, оценивается как несделанный, даже если «там одна ошибка типов и она неважная». Отдельная команда
tsc --noEmitперед сдачей стоит пятнадцати секунд. anyв сдаваемом коде читается однозначно. Эксперт видитanyна границе с сетью и понимает, что автор либо не разобрался, либо торопился. Заменить наunknownс проверкой — две минуты и другое впечатление.- Типы ответов ноды пишутся один раз и работают всю сессию. Пятнадцать минут в начале на интерфейсы к трём ручкам API возвращаются каждым последующим обращением: автодополнение вместо перечитывания документации.
- Не путай проверку типов с проверкой работы. Зелёная сборка ничего не говорит о том, что транзакция ушла в нужную сеть с нужными параметрами. Это проверяется запуском против ноды, и на это время закладывают отдельно.
Что должен уметь
- Объяснять одной фразой, чем TypeScript отличается от JavaScript, и почему на ноде исполняется только JavaScript.
- Рассказывать, что делает компилятор за один проход: проверяет типы и стирает их.
- Отличать
tsc --noEmitот сборки и понимать, зачем в проекте есть обе команды. - Ставить аннотации там, где они нужны: параметры функций и границы с внешним миром, — и не ставить там, где тип выводится.
- Описывать union-типом набор допустимых значений и сужать его проверками
typeofи сравнением. - Писать интерфейс для ответа ноды по живому JSON и объяснять, почему описывать весь ответ не надо.
- Читать дженерик в сигнатуре:
Promise<T>,Array<string>,Record<string, number>. - Объяснять, почему
anyотключает проверку и почемуunknown— правильная замена на границе с сетью. - Понимать, что делает
strict, и включать его в новом проекте с первого дня. - Типизировать ответ REST-запроса к ноде: интерфейс плюс проверка формы в коде.
- Перечислять, что типы ловят до запуска и что не ловят никогда.
- Читать чужой
.d.tsи находить в нём сигнатуру нужной функции. - Называть случаи, когда обычного JavaScript достаточно.
Углубиться
Структурная типизация. TypeScript сравнивает типы по форме, а не по имени. Объект, у которого есть все нужные поля, подойдёт под интерфейс, даже если про этот интерфейс он ничего не знает и объявлен в другом файле. Это отличается от Java и C#, где важно явное объявление «класс реализует интерфейс», и объясняет, почему интерфейсы к ответам ноды пишут свободно: достаточно описать нужные поля, лишние никому не мешают.
Типы-функции над типами. Кроме описания форм, TypeScript умеет вычислять типы из других типов: Partial<T> делает все поля необязательными, Pick<T, K> оставляет только перечисленные, Omit<T, K> — наоборот. Для скриптов к ноде это удобно, когда из одного большого интерфейса ответа нужно собрать маленький тип для внутренней функции.
Расхождение между .d.ts и реальностью. Файл объявлений пишет человек, и он может ошибиться или отстать от версии библиотеки. Если код ведёт себя не так, как обещают типы, прав всегда рантайм: типы стёрты, а движок исполняет то, что действительно написано в JavaScript.
Валидация вместо ручных проверок. Проверять форму ответа руками через typeof в большом проекте утомительно. Для этого существуют библиотеки схем, которые описывают форму один раз и дают и проверку в рантайме, и тип для компилятора. В учебном проекте они избыточны, но знать про существование класса инструментов полезно: ручная проверка не единственный путь.
Sound и unsound. Система типов TypeScript намеренно не является строго безопасной: в ней есть лазейки (any, as, индексация массива за границей), через которые в рантайм пролезает то, что типы запретили. Это осознанный компромисс ради совместимости с существующим JavaScript — и ещё одна причина не считать зелёную сборку доказательством правильности.
Дальше по теме:
- TypeScript for JavaScript Programmers — официальное введение ровно для того, кто уже знает JavaScript
- TypeScript Playground — редактор в браузере, который рядом показывает получившийся JavaScript: лучший способ увидеть стирание типов своими глазами
Сводная проверка по всей главе: 12 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
-
TypeScript for JavaScript Programmers — короткое официальное введение для тех, кто уже пишет на JavaScript.
-
Everyday Types — примитивы, массивы, объекты, union и псевдонимы типов: девяносто процентов повседневных аннотаций.
-
The Basics — как устроена проверка типов и что именно происходит при компиляции.
-
Narrowing — сужение union через
typeof,in,Array.isArrayи сравнения. -
Generics — дженерики от простого примера до ограничений через
extends. -
Declaration Files: Introduction — что такое
.d.ts, откуда он берётся и как его читать. -
TSConfig Reference — все опции
tsconfig.jsonс примерами и значениями по умолчанию. -
strict — описание флага — что именно включается одной строкой.
-
TypeScript Playground — редактор в браузере, показывающий рядом получившийся JavaScript.
-
Node.js: запуск TypeScript — официальное описание того, как Node стирает типы и почему это не проверка.
-
Node.js: введение в TypeScript — обзор способов запускать TypeScript-код в Node.
-
MDN: JSON.parse — что на самом деле возвращает разбор JSON и почему типов там нет.
-
MDN: fetch — первоисточник про
res.okи про то, что ответ 404 не считается ошибкой. -
MDN: типы данных JavaScript — какие типы существуют в самом языке, поверх которого работает TypeScript.
-
Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.