Що нового в TypeScript 5.0. Декоратори, покращення продуктивності та безпеки типізації
Привіт! Мене звати Андрій, і я JavaScript-розробник у компанії Rolique. Як видно із статистики мов програмування DOU, популярність TypeScript стрімко зростає. І я можу це підтвердити, адже практично на всіх останні моїх проєктах використовувався TypeScript як основна мова програмування.
Нещодавно вийшла нова версія TypeScript 5.0. Тож давайте розберемося які основні зміни відбулися.
Цей реліз приніс багато нових функцій, при цьому, як стверджують розробники, метою було зробити TypeScript меншим, простішим та швидшим:
- реалізували новий стандарт декораторів;
- додали кращу підтримку ESM-проектів в Node та бандлерах;
- покращили поведінку enum;
- розширили функціональність JSDoc;
- спростили конфігурацію та здійснили багато інших поліпшень.
Декоратори
Декоратори — це інструмент, який дозволяє розширювати поведінку класів, методів, властивостей об’єктів в декларативний спосіб, без зміни коду безпосередньо. Є висока ймовірність, що декоратори скоро стануть частиною стандарту ECMAScript, тому щоб зберегти сумісність, їх додали в TypeScript 5.0.
Розглянемо приклад:
class Person { name: string; constructor(name: string) { this.name = name; } greet() { console.log(`Hello, my name is ${this.name}.`); } } const p = new Person("Ron"); p.greet();
Метод greet
досить простий у цьому випадку, але уявімо, що в ньому міститься якась складна логіка. Природно, що ми захочемо додати console.log
, які за потреби дозволять віддебажити цей метод.
class Person { name: string; constructor(name: string) { this.name = name; }
greet() { console.log("LOG: Entering method."); console.log(`Hello, my name is ${this.name}.`); console.log("LOG: Exiting method.") } }
Такий підхід досить зручний і було б чудово мати можливість легко додавати логування і для інших методів. І тут нам у нагоді стають декоратори.
Декоратор — це функція, яка приймає target
(оригінальний метод, який ми обгортаємо декоратором) і context
(контекст виклику метода, який містить додаткову інформацію про «декорований» елемент, як його тип, ім’я і т. п.). Також ми повинні повернути нову функцію, в якій явно викликаємо target
. Ми можемо додати логіку перед і після цього виклику.
function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = String(context.name); return function (this: any, ...args: any[]) { console.log(`LOG: Entering method '${methodName}'.`) const result = originalMethod.call(this, ...args); console.log(`LOG: Exiting method '${methodName}'.`) return result; }; }
Тепер ми можемо використати loggedMethod
, щоб «декорувати» оригінальний метод greet
:
class Person { name: string; constructor(name: string) { this.name = name; } @loggedMethod greet() { console.log(`Hello, my name is ${this.name}.`); } } const p = new Person("Ron"); p.greet(); // Output: // // LOG: Entering method. // Hello, my name is Ron. // LOG: Exiting method.
Варто зазначити, що TypeScript у попередніх версіях підтримував «експериментальну» версію декораторів, яка вмикається флагом --experimentalDecorators
. Вони були побудовані на основі старішої версії, тому не повністю сумісні з новими змінами. Та через те що функціональність була досить популярною (зокрема активно використовувалася такими інструментами як Angular і Nest.js), вона і далі певний час буде доступна з увімкненим флагом --experimentalDecorators
. За замовчанням же будуть використовуватися «нові» ES-декоратори.
Якщо бажаєте детальніше ознайомитися з поточною версією ES-декораторів, рекомендую цю досить докладну статтю.
All enums Are Union enums
TypeScript має два типи enum: числовий та літеральний.
// числовий enum // якщо значення неініціалізовані явно // то вони ініціалізуються інкрементально enum LogLevelNumeric { Debug = 1, Info, // 2 Warn, // 3 Error, // 4 } // літеральний enum // значення повинні бути явно ініціалізовані // рядковими (String) або числовими (Number) літералами enum LogLevelLiteral { Debug = "DEBUG", Info = "INFO", Warn = "WARN", Error = "ERROR", }
Ці два типи enum мали різну поведінку у деяких випадках. Наприклад, у нас є функція, яка очікує параметром значення із enum. У випадку числового enum ми могли передати будь-яке число і це не викликало помилку компіляції.
enum LogLevel { Debug = 1, Info, // 2 Warn, // 3 Error, // 4 } function log(level: LogLevel, message: string) {} // Тут все гаразд log(1, "Debugging message") // Хоч у нас в LogLevel немає елемента ініціалізованого // значенням 999, TypeScript попередніх версій не відобразить помилку log(999, "It works also")
Якщо ж ми використовували літеральний enum, то для того, щоб передати параметр у функцію, ми повинні чітко передати конкретний елемент enum.
enum LogLevel { Debug = "DEBUG", Info = "INFO", Warn = "WARN", Error = "ERROR", } function log(level: LogLevel, message: string) {} // У цьому випадку буде помилка log("DEBUG", "It fails") // Тут усе правильно log(LogLevel.Debug, "Debugging message")
Починаючи з TypeScript 5.0, для кожного елементу enum буде створено свій тип і, відповідно, тип enum буде рівноцінний об’єднанню (Union) типів його елементів, незалежно від того чи це числовий, чи літеральний enum. Наприклад:
// Все ще працюватиме для числових enum log(1, "Debugging message") // У TypeScript 5.0 тут буде помилка! log(999, "It fails in TypeScript 5.0") // Для літеральних enum все ще потрібно передавати // безпосередньо елемент log(LogLevel.Debug, "Debugging message")
Ще одна зміна, яка стосується enum. Відтепер значення елементів enum можуть бути обчислені з використанням константних виразів для обох типів і неконстантних виразів — лише для числових enum. Наприклад, цей код не буде працювати у попередніх версіях TypeScript:
const baseValue = 10; const prefix = "/data"; const enum Values { First = baseValue, // 10 Second, // 11 Third // 12 } const enum Routes { Parts = `${prefix}/parts`, // "/data/parts" Invoices = `${prefix}/invoices` // "/data/invoices" }
const Type Parameters
При визначенні типу об’єкта, TypeScript зазвичай вибирає більш загальний тип. Наприклад, у цьому випадку типом для змінної names
буде string[]
:
type HasNames = { names: readonly string[] }; function getNamesExactly<T extends HasNames>(arg: T): T["names"] { return arg.names; } // Виведений тип: string[] const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
Це зроблено для того, щоб дозволити мутацію цього масиву нижче.
Однак, в залежності від того, що саме робить getNamesExactly
та як він використовується, інколи нам корисно отримати більш конкретний тип.
До версії TypeScript 5.0 зазвичай рекомендувалося додавати конструкцію as const
у певних місцях.
type HasNames = { names: readonly string[] }; function getNamesExactly<T extends HasNames>(arg: T): T["names"] { return arg.names; } // Виведений тип: string[] const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] }); // Виведений тип: // readonly ["Alice", "Bob", "Eve"] const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]} as const);
Такий підхід незручний, можна легко забути про as const
у потрібному місці. У TypeScript 5.0 тепер можна додавати модифікатор const
до декларації параметра типу, щоб зробити константне приведення типом за замовчуванням:
type HasNames = { names: readonly string[] }; function getNamesExactly<const T extends HasNames>(arg: T): T["names"] { // ^^^^^ return arg.names; } // Виведений тип: // readonly ["Alice", "Bob", "Eve"] // Немає потреби використовувати 'as const' тут const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
Варто зазначити, що модифікатор const
не накладає обмежень імутабельності на значення, тому в прикладі нижче ми повинні використовувати модифікатор readonly
для типу string[]
.
declare function fnBad<const T extends string[]>(args: T): void; // 'T' приводиться до 'string[]' тому що тип 'readonly ["a", "b", "c"]' не сумісний з 'string[]' fnBad(["a", "b" ,"c"]); declare function fnGood<const T extends readonly string[]>(args: T): void; // ^^^^^^^^ // 'T' приводиться до readonly ["a", "b", "c"] fnGood(["a", "b" ,"c"]);
Підтримка кількох конфігураційних файлів у extends
Працюючи з кількома проєктами корисно мати «базовий» файл конфігурації, який інші файли tsconfig.json
можуть успадковувати. Для цього у файлі конфігурації є поле extends
.
// packages/front-end/src/tsconfig.json { "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "../lib", // ... } }
Однак можуть бути випадки, коли ви захочете успадкувати конфігурацію одночасно з кількох файлів. Наприклад, уявіть, що ви використовуєте базовий файл конфігурації TypeScript, який поставляється через npm. Якщо ви хочете, щоб всі ваші проєкти також використовували параметри з пакета @tsconfig/strictest
, то є просте рішення: додайте в поле extends
базового файлу tsconfig.base.json
значення @tsconfig/strictest
:
// tsconfig.base.json { "extends": "@tsconfig/strictest/tsconfig.json", "compilerOptions": { // ... } }
Це буде працювати. Але якщо у вас є проєкти, в яких ви хочете також наслідувати налаштування з tsconfig.base.json
, але водночас вам не потрібні конфігурації @tsconfig/strictest
, то в цих проєктах потрібно або явно вимикати параметри, або створювати окрему версію tsconfig.base.json
, яка не успадковує @tsconfig/strictest
.
Щоб додати більше гнучкості, в Typescript 5.0 тепер дозволяється полям extends
мати кілька записів. Наприклад, як у цьому файлі конфігурації:
{ "extends": ["a", "b", "c"], "compilerOptions": { // ... } }
У цьому випадку файл конфігурації розширюється конфігурацією c
, яка в свою чергу розширюється конфігурацією b
, а b
— розширюється конфігурацією a
. У випадку коли якісь налаштування «конфліктують», то застосовуються параметри тієї конфігурації, яка знаходиться правіше у списку.
У наступному прикладі strictNullChecks
та noImplicitAny
включені в кінцевому tsconfig.json
.
// tsconfig1.json { "compilerOptions": { "strictNullChecks": true, "noImplicitAny": false } } // tsconfig2.json { "compilerOptions": { "noImplicitAny": true } } // tsconfig.json { "extends": ["./tsconfig1.json", "./tsconfig2.json"], "files": ["./index.ts"] }
Для демонстрації ми можемо переписати наш початковий приклад наступним чином:
// packages/front-end/src/tsconfig.json { "extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"], "compilerOptions": { "outDir": "../lib", // ... } }
—moduleResolution bundler
В TypeScript 4.7 були додані node16
і nodenext
опції для --module
і --moduleResolution
конфігурацій проєкту. Ці налаштування призначені для кращої підтримки ECMAScript модулів в Node.js. Проте ці режими мали певні обмеження. Наприклад, будь-які відносні імпорти ECMAScript модулів у Node.js вимагали вказувати розширення файлу.
import * as utils from "./utils"; // неправильно import * as utils from "./utils.mjs"; // правильно
Але для багатьох розробників, які використовують такі інструменти як бандлери, налаштування node16/nodenext
були громіздкими, оскільки бандлери в більшості не мають цих обмежень. Тому режим --moduleResolution node
був кращим для тих, хто користувався бандлерами.
Проте режим --moduleResolution node
вже застарів. Більшість сучасних бандлерів використовують поєднання ECMAScript і CommonJS модулів у Node.js. Щоб змоделювати як працюють бандлери, TypeScript додав нову стратегію: --moduleResolution bundler
.
Якщо ви користуєтеся сучасним бандлерами, таким як Vite, esbuild, swc, Webpack, Parcel і т. п., які реалізовують гібридну стратегію, новий варіант bundler
чудово вам підійде.
Проте, якщо ви пишете бібліотеку, щоб опублікувати її в npm, використання цього параметра може нести приховані проблеми сумісності, які можуть виникнути у ваших користувачів, що не використовують бандлер.
Покращення швидкодії, використання пам’яті і розміру пакета
Як стверджують розробники, TypeScript 5.0 містить багато суттєвих змін в структурі коду, структурах даних та реалізаціях внутрішніх алгоритмів. Це означає, що виконання і встановлення TypeScript тепер повинно бути швидшим.
Ось такі цифри (в порівнянні з TypeScript 4.9) наводяться в офіційному анонсі.
Сценарій | Час або розмір порівняно з TS 4.9 |
material-ui build time | 90% |
TypeScript Compiler startup time | 89% |
Playwright build time | 88% |
TypeScript Compiler self-build time | 87% |
Outlook Web build time | 82% |
VS Code build time | 80% |
typescript npm Package Size | 59% |
Як бачимо, TypeScript 5.0 має швидший час збирання проєкту приблизно на
Висновок
Отже, ми бачимо, що TypeScript активно підтримує сумісність із JavaScript і, додавши у новій версії декоратори, дозволяє використовувати можливості JS із «близького майбутнього» уже зараз. Також команда постійно працює над покращенням «безпеки» типів, що має тішити фанатів строгої типізації. Те, що окрім нових фіч, оптимізована робота самого TypeScript, показує, що мейнтейнерам проєкту не в останню чергу важливий і «користувацький досвід» розробників. Думаю, що це позитивно вплине і на так зростаючу кількість web-проєктів, в яких використовується ця мова програмування.
Це не вичерпний список змін, які зроблені у TypeScript 5.0. Крім згаданого, додані додаткові конфігурації, покращено підтримку JSDoc, сортування імпортів і автодоповнення для конструкції switch/case у редакторах коду. З цими змінами ви можете ознайомитися в офіціному анонсі TypeScript 5.0.
Дякую за прочитання! Віримо в перемогу і підтримуємо ЗСУ!
52 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів