Главная / Статьи

Что компилятор TypeScript обнаруживает, а что пропускает JavaScript

Сравнение JavaScript и TypeScript бок о бок: вывод типов, аннотации, примитивы, any, объединения и типизированные функции, а также то, что на самом деле фиксирует и выводит tsc.

Издатель: REACTAPP.TOP14 мин чтения3122 слов

Источники: JavaScript vs TypeScript: Understanding Type Safety

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

Динамическое типирование против статического типирования

JavaScript имеет динамическое типирование. У переменной нет фиксированного типа; тип определяется значением, которое она в данный момент содержит, формируется во время выполнения программы и может меняться в любой момент.

TypeScript — это статически типизированный суперсет JavaScript. Каждая валидная программа на JavaScript также является синтаксически корректной программой на TypeScript, но TypeScript добавляет проверку типов, которая анализирует код на этапе компиляции, до того как что-либо будет выполнено. Затем проверка удаляет информацию о типах и выводит обычный JavaScript.

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

Когда переменная меняет тип

В обычном JavaScript начните с числовой переменной и проверьте её тип с помощью typeof:

let age = 30;
console.log(typeof(age));  // output is number

Через несколько строк ничто не мешает сохранить строку в той же переменной:

age = "John";
console.log(age);
console.log(typeof(age)); // string

JavaScript принимает это без предупреждений или ошибок. Первый вызов typeof возвращает "number", а второй — "string": одна переменная, два несвязанных типа на протяжении всего срока её существования. В небольшом скрипте это неопасно; в крупной кодовой базе любой код, который всё ещё ожидает число от переменной age, будет работать некорректно далеко от строки, вызвавшей эту проблему.

TypeScript отклоняет аналогичный код:

let data = 2;
data = "two"; // ❌ Error: Type 'string' is not assignable to type 'number'

Когда TypeScript видит let data = 2;, он на основании начального значения делает вывод, что переменная data имеет тип number, и сохраняет этот тип на протяжении всего срока существования переменной. Это поведение называется инференцией типов. Поэтому присваивание значения "two" в следующей строке отклоняется компилятором ещё до запуска программы.

Вместо использования инференции вы можете сами указать тип:

let data: number = 2;

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

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

Ловушка строки и числа

В предыдущем примере показано, что TypeScript проверяет типы. Следующий пример объясняет, почему это важно.

JavaScript снова без проблем позволяет перезаписать переменную, в которой ранее хранилась строка, числом:

let message = "Hello";
let num = 2;
message = 20; // totally allowed in JS

Теперь классический сюрприз:

let result = "5" + 3;
console.log(result); // "53", not 8

Вы можете ожидать значение 8, поскольку "5" выглядит как числовой символ. Но "5" — это строка, и когда оператор + сталкивается со строками с обеих сторон, JavaScript выполняет их объединение вместо арифметических операций. Он преобразует 3 в текст и соединяет оба значения, получая "53". JavaScript выполняет эту операцию без ошибок, потому что он никогда заранее не проверяет типы; он просто выполняет то, что прямо указано в коде в момент его выполнения.

Что делает TypeScript с тем же кодом

Вот версия на TypeScript с явно указанными типами:

let num1: string = "5";
let num2: number = 3;

А сама операция сложения:

let add = num1 + num2; // expected 8, but num1 is a string and num2 is a number
console.log(add); // actual result is 53

Выведенное значение по-прежнему равно 53. Именно здесь многие объяснения ошибаются, поэтому важно быть точным. TypeScript намеренно разрешает операцию string + number, поскольку объединение текста с числом — это распространенная и допустимая практика в JavaScript. Проверщик не отклоняет такое выражение; он предполагает, что тип переменной add равен string.

Ошибка оператора, которую вы могли видеть, выглядит так:

Operator '+' cannot be applied to types 'string' and 'number'.

TypeScript выводит подобные сообщения для комбинаций операндов, которые он считает бессмысленными, например при сложении boolean с number или при сложении значений совершенно разных типов. Это не то, что происходит при операции num1 + num2, описанной выше.

В данной ситуации TypeScript защищает вас тогда, когда результат используется как число. Если бы вы написали let add: number = num1 + num2;, компилятор сообщил бы о том, что тип string нельзя присвоить типу number. Аналогичным образом, передача переменной add в функцию, ожидающую значение типа number, приведёт к ошибке. На практике именно так обнаруживается ошибка вида "5" + 3 в типизированном коде: не у знака плюса, а там, где случайно введённый строковый тип встречается с кодом, ожидающим число.

Почему ошибки по умолчанию не останавливают программу

Существует ещё одна тонкость. Даже когда tsc обнаруживает ошибку типа, по умолчанию он всё равно генерирует скомпилированный файл .js. Запуск этого файла с помощью node приводит к выполнению обычного JS-кода, больше ничего. Вся информация о типах удаляется, и остаётся только логика выполнения.

В этом суть TypeScript: он не меняет поведение вашего кода во время выполнения. Он выявляет проблемы в редакторе и во время компиляции, ещё до того, как вы что-либо запустите. Если вы хотите, чтобы компилятор отказывался генерировать результат при наличии ошибок типов, включите этот параметр в файле tsconfig.json:

{
  "compilerOptions": {
    "noEmitOnError": true
  }
}

При включенном режиме noEmitOnError код с ошибками типов никогда не попадает в node, поскольку инструмент tsc останавливается, не создавая файл JavaScript. В большинстве реальных проектов аналогичный эффект достигается в средах CI путем запуска команды tsc --noEmit в качестве отдельного шага проверки типов, при котором сборка завершается с ошибкой при обнаружении любых проблем.

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

Аннотации и вывод типов

Эти два термина описывают противоположные способы достижения одинакового результата. Рассмотрим объявление с аннотацией:

let age: number = 30;

Разберём по частям:

  • let — это ключевое слово для объявления
  • age — это имя переменной
  • 30 — это присвоенное значение
  • number — это тип
  • : number, тире с указанием типа, — это аннотация типа

Аннотация явно указывает TypeScript на тип переменной с помощью синтаксиса с двоеточием, например : number, : string или : boolean. Инференция работает наоборот: вы не указываете тип, и TypeScript определяет его по присвоенному значению.

let age = 30;        // inference — TS decides this is a "number" on its own
let city: string = "Delhi"; // annotation — you told TS explicitly

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

Примитивные и непримитивные типы

Полезный способ группировки типов, с которыми вы столкнетесь в начале:

  • Примитивные типы, такие как number, string, boolean, undefined и null, в каждый момент времени содержат ровно одно значение.
  • Непримитивные, или структурированные, типы, такие как массивы, объекты, кортежи, функции, а также структуры, описанные с помощью классов и интерфейсов, объединяют несколько значений — например, массив оценок или запись пользователя с именем и электронной почтой.

Примитив — это емкость, в которой находится один элемент; структурированный тип — это контейнер, в котором находится множество таких емкостей.

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

Примитивные типы на практике

Числа

Тип number в TypeScript охватывает как целые числа, так и значения с плавающей точкой. В отличие от многих других языков здесь нет отдельных типов int или float, поскольку сам JavaScript хранит обычные числа в виде 64-битных значений с плавающей точкой.

let age = 20;
let price = 26.2;
let big = 24757689;

Вывод их значения и проверка с помощью typeof:

console.log("Age :", age);
console.log("price:", price);
console.log(big);
console.log(typeof(age)); // "number"

Целые числа, дроби и большие значения — всё это просто number, и функция typeof возвращает для каждого из них значение "number". Для целых чисел, превышающих допустимый диапазон данного формата с плавающей точкой, в JavaScript предусмотрен отдельный тип bigint.

Строки

Строка — это последовательность символов: слово, предложение или любой текст. Литералы строк можно записывать с использованием одинарных кавычек, двойных кавычек или обратных слешей.

let firstname: string = 'JOHN';
let lastname: string = "SINGH";
console.log("Hello", firstname, lastname); // Hello JOHN SINGH

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

// let greeting: string = "Hello", firstname, lastname; // ❌ this does NOT concatenate

Внутри оператора let запятая используется для разделения нескольких объявлений; она не соединяет тексты. В этой строке объявляются три отдельные переменной — greeting, firstname и lastname, причем только переменной greeting присваивается значение. Если переменные firstname и lastname уже существуют в том же области видимости, TypeScript также выдаст ошибку о их повторном объявлении.

Запятые подходят для вывода нескольких значений, но чтобы сохранить объединенное предложение в переменной, используйте шаблонные литералы: строку, заключенную в обратные кавычки, с местами замены ${} для выражений.

let firstname: string = 'JOHN';
let lastname: string = "SINGH";
let greeting: string = `Hello ${firstname} ${lastname}`;
console.log(greeting); // Hello JOHN SINGH

Шаблонные литералы — это рекомендуемый способ смешивания текста и значений. Они выглядят более читаемо, чем цепочки операторов конкатенации +, и также позволяют использовать строки, расположенные на нескольких строках.

Логические значения

Логическое значение имеет ровно два возможных значения: true и false.

let isStudent: boolean = true;
let hasJob: boolean = false;

Логирование значения и проверка его типа:

console.log("Is student?", isStudent); // true
console.log(typeof (isStudent)); // "boolean"

Логические значения используются во всех условных операциях кода: операторах if, условиях циклов и в любых других местах, где принимается решение «да» или «нет».

Null и undefined

Как null, так и undefined обозначают отсутствие значения, но имеют разное значение. Начнем с переменной, которая была объявлена, но так и не получила значение:

let emptyvalue;
console.log(typeof emptyvalue); // "undefined"

Объявленная, но не присвоенная переменная содержит значение undefined, что означает, что переменная существует, но в ней пока не хранится никаких данных.

Вы также можете явно присвоить переменным типы null и undefined:

let emptyvalue: null = null;
let value: undefined = undefined

Затем сравните, как каждый из них отображается, а также переменную определенного типа, которой не было присвоено значение:

console.log(emptyvalue); // null
console.log(value);      // undefined
let price: number;
console.log(price); // undefined - declared, but not yet assigned any value

В простых словах разница заключается в следующем:

  • undefined означает, что переменная существует, но ей не присвоено значение; это стандартное состояние пустоты.
  • null означает, что разработчик намеренно установил переменную в значение «нет значения»; это целенаправленная, явная пустота.

Аннотация типа, такая как : number, ограничивает возможные значения, которые может принимать переменная в будущем; она не задаёт начальное значение. Поэтому во время выполнения price выводит undefined. Однако следует отметить, что при включённом режиме strict, который является стандартом в современных проектах, TypeScript выдаёт ошибку на строке console.log(price), поскольку переменная используется до того, как ей будет присвоено значение. Эта проверка — ещё один пример того, как компилятор выявляет ошибки, которые обычный JavaScript позволил бы остаться незамеченными.

В обычном коде вы редко будете аннотировать переменную как исключительно null или undefined; эти примеры предназначены для показа того, как ведут себя эти два состояния пустоты. Гораздо чаще используется сочетание вроде string | null для обозначения значения, которое может отсутствовать.

Тип any: отключение проверок

Используйте any редко и осознанно, поскольку он отключает именно те проверки, ради которых был создан TypeScript. Переменная типа any принимает любой вид значения и может быть переопределена в другой тип по желанию, точно как в обычном JavaScript.

let value: any = "abcd";
console.log(typeof value); // "string"

Переопределение её на число, а затем на логическое значение:

value = 123;
console.log(typeof value); // "number"
value = true;
console.log(typeof value); // "boolean"

Переменная меняет тип с строки на число, а затем на логическое значение без каких-либо проблем, потому что аннотация указала компилятору не проверять её.

Существуют законные случаи использования. Когда вы действительно не можете заранее определить тип, например при работе с непредсказуемыми внешними данными или во время поэтапной миграции с JavaScript, когда невозможно ввести всё сразу, тип any позволяет продолжать работу. Однако чрезмерное использование этого типа приводит к потере функции автодополнения, появлению ошибок на этапе компиляции и утрате той защиты, которая изначально побудила использовать TypeScript. Рассматривайте его как крайний вариант, а не как привычку. Для данных неизвестной структуры обычно лучше использовать тип unknown, поскольку он заставляет вас проверять значение перед его использованием. Наш гид о замене any на безопасные по типу паттерны подробно рассматривает альтернативы.

Типы-объединения: контролируемый набор вариантов

Тип соединения позволяет значению быть одним из нескольких определенных типов. В отличие от any, он не принимает всё подряд, а только те типы, которые вы указали. «Union» — это не ключевое слово; это название шаблона, записанное с помощью символа трубки |.

let id: number | string | boolean;

Присваивание каждого разрешенного типа по очереди:

id = "123abc";
console.log(id);
id = 12;
console.log(id);
id = true;
console.log(id);

Переменная id может содержать число, строку или логическое значение, и ничего больше. Попытка присвоить ей массив вроде [1, 2, 3] сразу же отклоняется, поскольку массивы не входят в этот набор типов. Это обеспечивает гибкость именно там, где она нужна: в реальном мире идентификаторы иногда поступают в виде чисел, а иногда — в виде строк, при этом сохраняется четкое ограничение относительно допустимого. Прежде чем использовать значение из набора типов в специфичном для конкретного типа контексте, TypeScript требует сузить его диапазон, например с помощью проверки typeof.

Типизация функций

Функции объединяют одну задачу в код, который можно использовать повторно. Вы определяете их один раз, а затем вызываете в любом месте и столько раз, сколько необходимо.

Функция без параметров

Самая простая версия содержит значения в виде жестко заданных констант:

function sum() {
  console.log(10 + 20);
}

Определение функции не приводит к её выполнению. Ничего не происходит, пока вы её не вызовете:

sum(); // calling the function → prints 30

Именно в этом и суть: написать логику один раз и выполнять её по мере необходимости.

Добавление параметров с типами

Функции становятся по-настоящему повторно используемыми, когда они принимают параметры — заменители, позволяющие одному и тому же коду работать с разными значениями при каждом вызове.

function sum(x: number, y: number) {
  console.log(x + y);
}

Вызов функции с двумя числами:

sum(10, 20); // prints 30

Теперь sum больше не связан с 10 и 20; sum(5, 7) и sum(100, 1) работают так же хорошо. Обозначение : number после каждого параметра является аннотацией типа для этого параметра, поэтому TypeScript отклоняет вызовы вроде sum("hello", 20) до того, как код начнет выполняться. Именно здесь TypeScript обычно оправдывает себя: на границах функций, когда одна часть программы передает данные другой.

В этой версии результат все еще выводится с помощью console.log. Функция не возвращает ничего вызывающему коду, что имеет значение на следующем этапе.

Объявление типа возвращаемого значения

Функция, которая возвращает свой результат, гораздо полезнее:

function sum(x: number, y: number): number {
  return x + y;
}

Захват и использование возвращаемого значения:

let res: number = sum(10, 20);
console.log(res); // 30

По сравнению с предыдущей версией изменилось три вещи:

  • return x + y; возвращает значение обратно вызывающему коду вместо того, чтобы вывести его внутри функции.
  • : number, стоящий после списка параметров в (x: number, y: number): number, представляет собой аннотацию типа возвращаемого значения. Она гарантирует, что функция всегда возвращает число, и любой возврат строки приведёт к ошибке компиляции.
  • Поскольку функция sum теперь возвращает значение, выражение let res: number = sum(10, 20); позволяет сохранить его для дальнейшего использования: записать в лог, передать другой функции или объединить с другими результатами.

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

Заключение

  • JavaScript определяет типы во время выполнения и полностью доверяется пользователю, что делает его гибким, но позволяет возникать скрытым ошибкам, таким как результат операции "5" + 3, равный "53".
  • TypeScript фиксирует тип каждой переменной с помощью аннотаций или автоматического определения и проверяет его на этапе компиляции, до запуска кода.
  • TypeScript не отклоняет все сомнительные выражения; объединение строк с числами допускается, а защита вступает в силу тогда, когда полученная строка попадает в код, ожидающий число.
  • По умолчанию ошибки типов не прерывают вывод; для блокировки сборки используйте параметр noEmitOnError или команду tsc --noEmit в процессах CI.
  • Используйте тип any только в случаях действительно неизвестных данных, предпочитайте объединения типов и unknown для большей гибкости, а также аннотируйте параметры функций и типы возвращаемых значений там, где данные пересекают границы.

Результат выполнения кода на JavaScript и TypeScript одинаков. Разница проявляется тогда, когда вы обнаруживаете ошибку: в вашем редакторе, а не у пользователей.