Головна / Статті / TypeScript 6.0 пояснено: зміни в компіляторі та оволодіння генеріками

TypeScript 6.0 пояснено: зміни в компіляторі та оволодіння генеріками

Розбирає зміни в перехідному компіляторі TypeScript 6.0 та показує, як використовувати генеріки для створення безпечнішого та більш повторно використовуваного коду на рівні типів.

2691 слів

TypeScript продовжує розвиватися одночасно у двох напрямках: сам компілятор стає міцнішим та швидшим, водночас найпотужніший інструмент типової системи — генерики — залишається ключем до написання коду, який залишається безпечним під час масштабування. Розуміння змін у TypeScript 6.0 та оволодіння генериками допомагають чітко бачити, як писати TypeScript, який є стійким до майбутніх змін та справді може використовуватися знову. Почніть із змін у компіляторі, адже саме вони визначають базовий рівень, на якому буде працювати кожна кодова база, що активно використовує генерики.

Перехідна версія на шляху до нативного компілятора

TypeScript 6.0 — це не стільки версія з новими функціями, призначена спеціально для вас, скільки місток. Команда TypeScript чітко описала її як перехідну версію: останню версію, створену на основі початкового кодового базису, заснованого на JavaScript, перед тим, як TypeScript 7.0 вийде у вигляді повної переробки на мові Go. Якщо оновлення до 6.0 здається надзвичайно спокійним, це навмисно. Більшість значних змін відкладені до версії 7.0.

Що насправді змінюється

У версії 6.0 впроваджено кілька конкретних змін:

  • Режим суворості тепер є стандартним для нових проєктів. Вам більше не потрібно вказувати "strict": true — натомість кодові бази, які використовують слабку типізацію, повинні явно встановити "strict": false. Намір команди зрозумілий: вони хочуть, щоб ви виправляли основні проблеми з типами, а не приховували їх.
  • Масив types за замовчуванням порожній. Раніше TypeScript автоматично завантажував усі пакети з node_modules/@types. Тепер нічого не завантажується, якщо ви не вказали це явно, що може суттєво скоротити час збірки у більших проектах.
  • За замовчуванням значення target та module дорівнюють es2025 та esnext відповідно. Генерація коду у старому форматі ES5 фактично вже не використовується.
  • З’явились типи вбудованої API Temporal, які дозволяють ефективно обробляти дати та час із забезпеченням типобезпеки без необхідності використання бібліотек на кшталт date-fns чи Luxon. Це основна функція, про яку просили розробники.
  • // Before: juggling Date math and timezone offsets manually
    const deadline = new Date(Date.now() + 86400000);
    
    // TypeScript 6.0: Temporal makes intent explicit
    const now = Temporal.Now.zonedDateTimeISO("Asia/Kolkata");
    const deadline = now.add({ hours: 24 });
    
    • Якість інференції покращується для функцій, які не використовують this, а TypeScript тепер підтримує імпорти підшляхів із префіксом #, а також поєднання параметрів moduleResolution: bundler та module: commonjs — таке поєднання раніше було неможливим.
    • Стандартна бібліотека отримала методи Map.getOrInsert, Map.getOrInsertComputed та вбудований метод RegExp.escape(), що усуває потребу у власних інструментах для ескейпування.
    • Параметр --baseUrl вважається застарілим. Перенесіть псевдоніми шляхів у поле paths у вашому tsconfig до того, як у версії 7.0 baseUrl буде остаточно видалено.

    Чому важлива перехідна природа

    Як пояснює команда TypeScript, метою цього випуску є підготовка розробників до версії 7.0, яка пропонує компілятор на основі Go, що забезпечує крокове створення проектів на 40–60% швидше, ніж зараз. Практичний висновок полягає у тому, що цей випуск — це ваш домашній завдання: усуньте попередження про застарілі елементи вже зараз, щоб версія 7.0 здавалась безкоштовним покращенням продуктивності, а не перешкодливою міграцією, особливо на сучасних середовищах виконання Node.js.

    Що робити перед виходом 7.0

    • Запустіть tsc --init та ознайомтесь із новими помилками у режимі суворості, які він виявляє на ранніх етапах.
    • Перенесіть налаштування з baseUrl на paths вже зараз, щоб уникнути проблем після їх видалення.
    • Явно вказуйте масив types, замість того щоб покладатися на автоматичне завантаження.
    • Почніть використовувати Temporal у коді з низьким рівнем ризиків, щоб ознайомитися з ним.

    TypeScript має довгу історію перетворення сучасних найкращих практик на стандарти майбутнього. Версія 6.0 — це спокійний етап перед тим, як настане майбутнє зі швидкістю нативного коду; скористайтеся нею, щоб привести свою конфігурацію до ладу, щоб при виході версії 7.0 перехід майже не помітився.

    Оновлення версій та флаги компілятора — це лише половина справи при написанні якісного TypeScript. Інша половина полягає у тому, щоб знати, як структурувати власні типи так, щоб компілятор справді міг вам допомогти — і саме це приводить нас до генериків, які, мабуть, є єдиною функцією, що відрізняє розробників, які борються з системою типів, від тих, хто використовує її вправно.

    Майже кожен розробник TypeScript стикається з однаковою проблемою на початку своєї роботи. Спочатку ця мова здається схожою на ретельного бібліотекаря, який стежить за кожним вашим кроком: ви створюєте інтерфейс для User, ще один для Product, ще один для BlogPost, і все залишається організованим та безпечним.

    Потім база коду починає зростати.

    Вам потрібна функція для отримання даних User з API, потім ще одна для Product, а ще одна — для BlogPost. Або ж ви намагаєтесь уникнути цієї проблеми, створивши один спільний обгорток, але потрапляєте у проблеми з компіляцією, і врешті-решт починаєте використовувати any скрізь, щоб прибрати червоні попередження — не усвідомлюючи при цьому, що таким чином усуваєте захисні механізми, які TypeScript мав би вам надати.

    Саме ця перешкода зупиняє багатьох розробників. Щоб її подолати, необхідно справді зрозуміти генерики.

    Жанрові елементи — це не синтаксичний трюк, який потрібно запам’ятати для співбесіди. Це структурна основа коду, яка є повторно використовуваною, підтримуваною та масштабованою. Як тільки ви зрозумієте цю концепцію, ви перестанете вручну створювати повторюваний шаблонний код та почнете проектувати системи так, як це робив би досвідчений інженер.

    Створення правильної ментальної моделі

    На мить забудьте про формальну теорію комп’ютерних наук та подумайте про те, як поводиться звичайна функція JavaScript. Ви ніколи не вводите конкретне значення безпосередньо у тіло функції:

    // Hardcoded: Only works for one specific person
    function greetRahul() {
      return "Hello, Rahul!";
    }
    
    // Dynamic: Uses a parameter as a placeholder for data
    function greet(name: string) {
      return `Hello, ${name}!`;
    }
    

    Параметр name — це лише замінник значення, яке буде передано пізніше, під час виклику.

    Генерік працює абсолютно так само — лише замість того, щоб позиціонуватися як замінник значення, він є замінником типу. Функції, класи та інтерфейси можуть приймати типи як аргументи так само, як звичайні функції приймають значення як аргументи.

    Box<T>. Якщо покласти туди ноутбук, він стане Box<Laptop>; якщо взуття — Box<Shoes>. Сама коробка байдужа до свого вмісту, але ви завжди знаєте, що всередині: якщо відкрити Box<Laptop>, ви знаєте, що його можна увімкнути; якщо Box<Shoes>, ви знаєте, що його можна носити. Немає жодних причин для здогадок.

    Проблема дублювання, яку усувають генерики

    Уявіть утиліту, яка обгортає дані разом із метаданими, такими як час створення та генерований ID. Без генериків доводиться писати майже ідентичну утиліту для кожної моделі в вашому додатку:

    // The Brute-Force Approach: Duplicate functions for every entity
    interface User {
      name: string;
      role: string;
    }
    
    interface Product {
      title: string;
      price: number;
    }
    
    function wrapUser(item: User) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    function wrapProduct(item: Product) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    Це пряме порушення принципу DRY — двадцять моделей даних означає двадцять майже ідентичних функцій обгортання.

    Спокусливим шляхом є використання any, щоб позбутися дублювання:

    function wrapItem(item: any) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    const wrapped = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript has no idea what 'wrapped.data' is!
    // Autocomplete is dead. Typos will crash in production.
    console.log(wrapped.data.nonExistentProperty); // Compiles without error, fails at runtime!
    

    Компілятор перестає скаржитися, але ви платите за цю тишу втратою безпеки типів, автодоповнення та можливостей безпечного рефакторингу — саме того, що забезпечує TypeScript.

    Кращим підходом є вираження тієї самої утиліти за допомогою генериків:

    function wrapItem<T>(item: T) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    Синтаксис <T> виконує три завдання одночасно. По-перше, він оголошує змінну типу під назвою T, яку буде використовувати ця функція. По-друге, вказування параметра у форматі (item: T) означає, що тип аргументу буде таким самим, яким стане T під час виклику функції. По-третє, оскільки тип повернення також посилається на T, точний тип вхідних даних передається без змін у результат, у цьому випадку у вигляді data: T.

    const userResult = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript automatically infers that T is { name: string; role: string }
    console.log(userResult.data.name); // Full autocomplete works!
    console.log(userResult.data.invalidProp); // Error: Property 'invalidProp' does not exist!
    

    Якщо викликати цю функцію з об’єктом у форматі User, TypeScript автоматично визначає тип T — без необхідності анотацій — та передає цей визначений тип у всю структуру поверненого значення data. Таким чином, userResult.data.name отримує повну автодоповнюваність та перевірку типів, замість сліпої довіри до типу any.

    Додавання обмежень для визначення меж

    Залишати T абсолютно неконтрольованим підходить для загальних допоміжних функцій у стилі ідентифікаторів, але багато реальних функцій потребують певних припущень щодо форми вхідних даних. Замість того, щоб приймати буквально все, часто хочеться сказати «будь-який тип, за умови, що він має такий вигляд». Саме це дають загальні обмеження, використовуючи ключове слово extends для визначення того, що може бути T.

    Розглянемо функцію, призначену для виведення унікального ID елемента:

    // This causes a compiler error!
    function printId<T>(entity: T) {
      console.log(entity.id);
      // Error: Property 'id' does not exist on type 'T'.
    }
    

    Цей код не компілюється, тому що TypeScript не має інформації про те, що T має поле id. T може бути як number, так і boolean, null або порожнім об’єктом, жоден з яких не гарантує наявність поля .id.

    Рішення полягає у обмеженні T до форми, яка містить id:

    interface HasId {
      id: string | number;
    }
    
    function printId<T extends HasId>(entity: T) {
      // Safe! TypeScript guarantees entity has an 'id' property.
      console.log(`Entity ID: ${entity.id}`);
      return entity;
    }
    
    // Works perfectly:
    printId({ id: 101, name: "Database Record" });
    printId({ id: "usr_99", email: "dev@example.com" });
    
    // Fails at compile time before hitting production:
    printId({ name: "Unsaved Item" });
    // Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'HasId'.
    

    Запис T extends HasId повідомляє компілятору, що він може приймати будь-який тип, за умови, що він задовольняє мінімальну вимогу наявності властивості id.

    Безпечні пошуки за допомогою keyof

    Класичною причиною помилок у JavaScript є спроба отримати доступ до властивості, якої немає, часто через опечатку, наприклад user.fristName замість user.firstName. Поєднання генериків з оператором keyof дозволяє створювати інструменти, в яких такі помилки стають структурно неможливими.

    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    const employee = {
      id: 42,
      name: "Sarah Connor",
      department: "Security",
      isActive: true,
    };
    
    // Autocomplete offers: "id" | "name" | "department" | "isActive"
    const empName = getProperty(employee, "name"); // Type inferred as: string
    const empActive = getProperty(employee, "isActive"); // Type inferred as: boolean
    
    // Typos are caught immediately:
    const badProp = getProperty(employee, "deparment");
    // Error: Argument of type '"deparment"' is not assignable to parameter of type '"id" | "name" | "department" | "isActive"'.
    

    Ось чому ця схема настільки потужна:

    • T позначає форму об’єкта, з яким ви працюєте.
  • keyof T створює об’єднання всіх дійсних ключів у T, наприклад "id" | "name" | "department" | "isActive".
  • K extends keyof T змушує key бути однією з цих літеральних рядків, нічим іншим.
  • T[K] змушує тип повернення відповідати саме типу значення, яке зберігається за цим ключем.
  • Те, що виглядає як невелика допоміжна функція, насправді є контрактом на етапі компіляції, який повністю виключає можливість помилок у назвах властивостей.

    Проектування багаторазового клієнта API

    Окрім ізольованих утиліт, генерики справді приносять користь у коді великого масштабу. Майже кожен веб-додаток взаємодіє з якимось бекендом, а більшість REST-API обгортають свої відповіді у єдиний JSON-формат:

    {
      "status": "success",
      "statusCode": 200,
      "data": { ... },
      "message": "Operation successful"
    }
    

    Замість того, щоб вручну створювати окремий тип відповіді для кожного ендпоїнта, ви можете визначити один універсальний шаблон та використовувати його скрізь:

    // 1. The Generic Contract
    interface ApiResponse<TData> {
      status: "success" | "error";
      statusCode: number;
      data: TData;
      message?: string;
    }
    
    // 2. The Pagination Envelope
    interface PaginatedList<TItem> {
      items: TItem[];
      totalCount: number;
      page: number;
      pageSize: number;
    }
    

    З наявністю такого контракту сам клієнт HTTP стає значно компактнішим та більш придатним для повторного використання:

    async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    }
    
    // Concrete Domain Models
    interface UserProfile {
      id: string;
      username: string;
      email: string;
    }
    
    interface OrderHistory {
      orderId: string;
      totalAmount: number;
      currency: string;
    }
    
    // Usage Example 1: Fetching a single user
    async function loadUser() {
      const response = await fetchApi<UserProfile>("/api/v1/profile");
    
      // Fully typed:
      console.log(response.data.username);
    }
    
    // Usage Example 2: Fetching a paginated list of orders
    async function loadOrders() {
      const response = await fetchApi<PaginatedList<OrderHistory>>("/api/v1/orders");
    
      // Fully typed nested structures:
      response.data.items.forEach(order => {
        console.log(`Order #${order.orderId}: ${order.totalAmount}`);
      });
    }
    

    Переваги тут значні: без необхідності створення окремої функції отримання даних для кожного маршруту кожен ендпоїнт автоматично успадковує повну безпеку типів від запиту до відповіді, а також точне автодоповнення та значно простіше довгострокове обслуговування.

    Використання генериків у компонентах UI, які можна повторно використовувати

    Той самий принцип природним чином поширюється на рівень компонентів. Якщо ви створюєте інтерфейси у React, Vue чи звичайних Web Components, ймовірно, ви колись писали компоненти у форматі спадного списку, таблиці чи списку. Без генериків ці повторно використовувані компоненти схильні припиняти працювати як тільки з’являється потреба обробляти дані різної форми.

    Візьмемо за приклад генеричний компонент таблиці, створений у React:

    interface TableProps<T> {
      data: T[];
      renderRow: (item: T, index: number) => React.ReactNode;
      keyExtractor: (item: T) => string | number;
    }
    
    export function GenericTable<T>({ data, renderRow, keyExtractor }: TableProps<T>) {
      return (
        <table>
          <tbody>
            {data.map((item, index) => (
              <tr key={keyExtractor(item)}>
                {renderRow(item, index)}
              </tr>
            ))}
          </tbody>
        </table>
      );
    }
    

    Його використання виглядає так:

    interface Customer {
      id: string;
      fullName: string;
      loyaltyPoints: number;
    }
    
    const customers: Customer[] = [
      { id: "c1", fullName: "Elena Rostova", loyaltyPoints: 450 },
      { id: "c2", fullName: "David Miller", loyaltyPoints: 1200 },
    ];
    
    function CustomerList() {
      return (
        <GenericTable
          data={customers}
          keyExtractor={(customer) => customer.id} // customer is inferred as Customer!
          renderRow={(customer) => (
            <>
              <td>{customer.fullName}</td>
              <td>{customer.loyaltyPoints} pts</td>
            </>
          )}
        />
      );
    }
    

    Зверніть увагу: тут немає перетворення типів за допомогою as Customer, немає any, і не потрібно нічого здогадувати. Якщо колега пізніше перейменує fullName на name у інтерфейсі Customer, TypeScript негайно виявить у всьому інтерфейсі користувацького інтерфейсу місця, які все ще потребують корекції.

    Забезпечення читабельності генериків: три правила

    Генеріки є потужними, але ця потужність спонукає до надмірної складності проектування. Бази коду іноді стають схожими на „чудовиська“ у вигляді таких конструкцій, як ProcessData<T, Record<string, T, U, V W extends keyof>>. Цей заплутаний стан часто називають „генерічним супом“, і він перетворює інакше простий код на головоломку, до якої ніхто не хоче торкатися.

    Три звички допомагають зберігати генеріки читабельними, а не заплутаними. Одним із поширених антипатернів, на які варто звертати увагу, є використання параметра типу, який з’являється лише один раз у сигнатурі функції:

    // ❌ OVER-ENGINEERED: T is only used once
    function logMessage<T extends string>(message: T): void {
      console.log(message);
    }
    
    // ✅ CLEAN & DIRECT: No generic required
    function logMessage(message: string): void {
      console.log(message);
    }
    

    Якщо параметр типу використовується лише один раз, зазвичай це не виправдовує його складності, і його часто можна замінити на конкретний тип.

    Підсумок: зміна мислення

    Написання коду, який працює лише з одним конкретним типом даних, робить вас розробником. Але саме написання коду, який залишається повторно використовуваним, комбінованим та безпечним щодо типу для будь-якого типу даних, відрізняє справжнього досвідченого розробника.

    Генерики допомагають уникнути повторюваного, крихкого коду та спрямовують до архітектур, які за своєю суттю є гнучкими та стійкими. Наступного разу, коли ви помітите, що дублюєте інтерфейс, клонуєте допоміжну функцію чи використовуєте any, зупиніться та запитайте себе, чи не може це значення стати параметром типу. Як тільки цей інстинкт стане автоматичним, ви перестанете просто швидше писати код на TypeScript та почнете створювати системи, які майже не схильні до несподіванок під час виконання.

    Пов’язана література