Що компілятор TypeScript виявляє, а JavaScript пропускає
Порівняймо JavaScript та TypeScript поруч: інференція типів, анотації, примітиви, any, союзи та типовані функції, а також те, що насправді фіксує та виводить tsc.
Джерела: 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 у функцію, яка очікує число, призведе до помилки. На практиці саме так виявляється помилка типу “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"
Змінна переходить від типу string до number, а потім до 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 є ідентичним. Змінюється лише те, коли ви дізнаєтесь про проблему: у вашому редакторі, а не перед користувачами.