Головна / Статті / Основи TypeScript: від перших анотацій до генериків та режиму строгості

Основи TypeScript: від перших анотацій до генериків та режиму строгості

Структурований огляд системи типів TypeScript — від примітивних типів та автоматичного визначення типу до дискримінованих союзів, генериків, допоміжних типів та суворих налаштувань tsconfig.

3750 слів

Кожен розробник JavaScript знає цю схему: код працює локально, його розсилають, а через два дні надходить звіт про помилку — функція отримала об’єкт замість рядка або у формулу потрапило undefined. JavaScript не заважає вам писати такий код; він просто призводить до помилки пізніше, під час виконання, зазвичай у продакшені. TypeScript переміщує цю помилку на момент, коли ви пишете відповідний рядок. Цей посібник проведе вас від першого let x: number через механізми звуження типів, дискриміновані союзи, генеричні типи та корисні типи до налаштувань компілятора, які визначають рівень захисту, щоб ви могли з упевненістю читати та писати TypeScript для продакшену.

Що таке TypeScript та чим він не є

TypeScript — це мова з відкритим кодом від Microsoft, яка додає статичну систему типів до JavaScript. Під „статичною“ тут мається на увазі те, що компілятор перевіряє типи до виконання — у вашому редакторі чи під час компіляції, а не під час виконання. Три факти визначають усе інше:

  • Це надмножина JavaScript. Будь-який синтаксично коректний код JavaScript є також коректним з точки зору синтаксису TypeScript, тому ви розширюєте те, що вже знаєте, а не починаєте з нуля. Перевірювач все одно може повідомляти про помилки в такому коді, і саме це є метою.
  • Він компілюється у звичайний JavaScript. Браузери та Node.js виконують JavaScript, тому компілятор tsc видаляє анотації та створює звичайні файли .js.
  • Типи зникають під час виконання. Вони існують для захисту під час розробки. На відміну від Java, де інформація про типи зберігається у скомпільованому байткоді, після запуску програми нічого не залишається про типи TypeScript.
  • Різниця в одній функції

    Ось звичайна функція JavaScript, яка додає два значення:

    function add(a, b) {
      return a + b;
    }
    

    Якщо її викликати з рядком та числом, вона не кидає помилок. Вона об’єднує значення, і ви помітите проблему лише тоді, коли сума на рахунку-фактурі виглядатиме дивно:

    add("10", 20); // "1020" — silently wrong
    

    Вказування типів параметрів та значень, які повертаються, чітко формулює контракт:

    function add(a: number, b: number): number {
      return a + b;
    }
    

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

    add("10", 20);
    // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
    

    Налаштування проекту

    Якщо у вас встановлений Node.js, ви можете глобально встановити компілятор та переконатися, що він працює:

    npm install -g typescript
    tsc --version
    

    Для реальних проектів встановіть TypeScript як локальну залежність для розробки, щоб усі учасники та сервер CI використовували однакову версію, а потім створіть файл конфігурації:

    npm install typescript --save-dev
    npx tsc --init
    

    Створений файл tsconfig.json є панеллю керування, яка визначає, наскільки суворим та сучасним має бути компілятор. Останні версії tsc --init вже активують режим strict, а TypeScript 6.0 зробив цей режим стандартним, проте у старіших проектах часто використовуються більш лояльні налаштування, які команди згодом посилюють. У розділі конфігурації нижче йдеться про це.

    Мінімальний перший файл оголошує змінну з типом та записує її:

    // app.ts
    let message: string = "Hello, TypeScript";
    console.log(message);
    

    Скомпілюйте його, а потім запустіть створений JavaScript:

    tsc app.ts     # produces app.js
    node app.js    # Hello, TypeScript
    

    Щодня більшість проектів уникають цього двокрокового підходу та запускають файли за допомогою ts-node або tsx, або використовують інструменти для об’єднання коду, такі як Vite, esbuild чи webpack, які компілюють код на льоту. Проте іноді варто зробити це вручну, адже це допомагає сформувати правильну когнітивну модель: вхідний код — TypeScript, вихідний — JavaScript.

    Основні типи та автоматичне визначення

    Примітивні типи та масиви

    Анотації для примітивних типів — це string, number та boolean:

    let username: string = "sanajit";
    let age: number = 26;
    let isActive: boolean = true;
    

    Масиви пишуться з типом елемента, який слідує за дужками:

    let scores: number[] = [90, 85, 78];
    let tags: string[] = ["typescript", "javascript"];
    

    Генерична форма Array<number> означає точно те саме; виберіть один стиль та дотримуйтесь його послідовно:

    // equivalent generic syntax
    let ids: Array<number> = [1, 2, 3];
    

    Дозвольте автоматичному визначенню виконувати очевидну роботу

    Рідко буває потреба коментувати ініціалізовані змінні. TypeScript визначає тип на основі значення та потім забезпечує його дотримання:

    let city = "Ahmedabad"; // inferred as string
    city = 42;              // Error: Type 'number' is not assignable to type 'string'
    

    Написання let city: string = „Ahmedabad“ не є помилкою, просто зайвим. Хороше правило для всієї мови: покладайтеся на автоматичне визначення типу, коли значення чітко вказує на нього, а явно вказуйте типи там, де значення є неоднозначним або коли ви визначаєте контракт, наприклад параметри функцій, типи повернення, порожні масиви та сигнатури калебеків.

    any, unknown, never та void

    Ці чотири спеціальні типи створюють труднощі майже у кожного початківця:

    • any вимикає перевірку типу для значення. Це радше вихідний механізм, а не справжній тип, і надмірне використання є найпоширенішою причиною того, чому кодова база перетворюється на JavaScript із додатковою синтаксисом.
  • unknown є безпечною альтернативою. Йому можна присвоїти будь-яке значення, але ви не зможете його використати, поки не обмежите можливості за допомогою перевірки. Це правильний вибір для даних, форма яких ще невідома, наприклад, для відповіді API перед її верифікацією.
  • never описує значення, яке не може існувати, наприклад, результат функції, яка завжди кидає помилку або ніколи не повертає значення. Його найпрактичніше застосування — це перевірка повноти охоплення, що дозволяє компілятору підтвердити, що кожен випадок switch оброблений.
  • void позначає функцію, яка не повертає жодної корисної інформації, наприклад, обробник подій або обгортку для логування.
  • За допомогою unknown перевірка typeof дозволяє використовувати методи рядка лише всередині захищеного відгалуження:

    function process(value: unknown) {
      if (typeof value === "string") {
        console.log(value.toUpperCase()); // safe — narrowed to string
      }
    }
    

    Функція, яка завжди кидає виняток, має тип never, тоді як функція, яка лише виконує побічний ефект, повертає void:

    function fail(message: string): never {
      throw new Error(message);
    }function logAction(action: string): void {
      console.log(`Action: ${action}`);
    }
    

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

    Опис об’єктів за допомогою інтерфейсів та псевдонімів типів

    Форму об’єкта можна описати безпосередньо в коді:

    const employee: { id: number; name: string; active: boolean } = {
      id: 1,
      name: "Amit",
      active: true,
    };
    

    Це працює один раз, але повторення тієї самої форми в кожному місці швидко перетворюється на зайвий код. Названня форми за допомогою interface або type вирішує цю проблему.

    Інтерфейси

    Інтерфейс надає назву формі об’єкта, щоб її можна було повторно використовувати:

    interface Employee {
      id: number;
      name: string;
      department: string;
    }
    

    Кожен об’єкт, позначений цим інтерфейсом, має відповідати оголошеним полям:

    const employee1: Employee = { id: 1, name: "John", department: "IT" };
    const employee2: Employee = { id: 2, name: "Sara", department: "HR" };
    

    Знак запитання позначає властивість, яка може бути відсутня:

    interface User {
      id: number;
      name: string;
      phone?: string; // may or may not be present
    }
    

    Властивості readonly можна присвоїти під час створення об’єкта, але не пізніше:

    interface Product {
      readonly id: number;
      name: string;
    }
    

    Спроба їх переприсвоєння призводить до помилки на етапі компіляції:

    const laptop: Product = { id: 100, name: "MacBook" };
    laptop.id = 200; // Error: Cannot assign to 'id' because it is a read-only property
    

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

    interface Person {
      name: string;
      age: number;
    }
    

    Потім створіть більш конкретну форму на її основі:

    interface Employee extends Person {
      employeeId: number;
      department: string;
    }
    

    Псевдоніми типів

    Псевдонім type також може використовуватися для найменування форм об’єктів, але не обмежується лише ними. Об’єднання, примітиви та кортежі також можуть отримати ім’я цим способом:

    type ID = string | number;
    type Point = { x: number; y: number };
    type Status = "pending" | "shipped" | "delivered";
    

    Вибір між інтерфейсом та типом

    Для звичайних форм об’єктів ці два поняття в більшості випадків є взаємозамінними. Справжні відмінності полягають у:

    • Розширення: інтерфейси використовують оператор extends; псевдоніми типів поєднують форми за допомогою оператора перетину &.
    • Об’єднання та примітиви: їх можна виразити лише за допомогою псевдонімів типів, як у прикладі type A = string | number.
    • Злиття декларацій: два інтерфейси з однаковою назвою зливаються в один; псевдоніми типів не можна передекларувати.
    • Конвенція: інтерфейси зазвичай використовуються для форм публічних API та контрактів класів; псевдоніми типів — для об’єднань, туплів та відображених чи умовних типів.

    Правило, яке дотримуються багато команд: використовувати interface кожного разу, коли форма ймовірно буде розширюватися, наприклад для параметрів React, моделей API та контрактів класів, а type — для об’єднань, туплів та всіх типів, що не є об’єктами.

    Об’єднання, звуження та перетини

    Типи об’єднання

    Юніон означає, що значення може належати до кількох типів:

    function printId(id: string | number) {
      console.log(`Your ID is ${id}`);
    }
    

    Обидва наведені нижче виклики є допустимими, оскільки кожен аргумент відповідає одному з елементів юніону:

    printId(101);
    printId("A-204");
    

    Звуження типу

    Коли значення має тип юніону, TypeScript дозволяє виконувати лише ті операції, які є дійсними для кожного елемента, поки ви не доведете, який саме елемент у вас є. Це доведення називається звуженням типу, і компілятор слідує йому протягом усього потоку керування:

    function formatValue(value: string | number) {
      if (typeof value === "string") {
        return value.toUpperCase(); // TypeScript knows it's a string here
      }
      return value.toFixed(2); // and here, it knows it's a number
    }
    

    Окрім typeof, поширеними засобами звуження типу є Array.isArray(), instanceof, оператор in та перевірки на рівність з null або undefined. Саме так TypeScript залишається мовчазним у звичайних випадках та все одно виявляє незвичайні ситуації.

    Дискриміновані юніони для стану

    Загальноприйнята помилка серед розробників середнього рівня — це надання кожній версії об’єднання спільного поля „тегу“ у вигляді літералу. Спочатку необхідно окремо визначити кожен стан:

    type LoadingState = { status: "loading" };
    type SuccessState = { status: "success"; data: string[] };
    type ErrorState = { status: "error"; message: string };
    

    Потім їх поєднують та використовують тег для переходу:

    type FetchState = LoadingState | SuccessState | ErrorState;function render(state: FetchState) {
      switch (state.status) {
        case "loading":
          return "Loading...";
        case "success":
          return `Loaded ${state.data.length} items`;
        case "error":
          return `Failed: ${state.message}`;
      }
    }
    

    Усередині кожного case значення state обмежується відповідною версією, тому state.data доступний лише у гілці "success", а state.message — лише у гілці "error". Неможливі комбінації, наприклад наявність даних та помилки одночасно, просто не можуть бути представлені. Це усуває цілу категорію збоїв типу „властивість невизначена“ у коді користувацького інтерфейсу та API. Додавання гілки default, яка присвоює state змінній never, перетворює це на перевірку повноти охоплення: якщо пізніше додати четвертий стан, компілятор вкаже на кожен оператор switch, який про нього забув.

    Типи перетину

    Якщо об’єднання означає „це або те“, то перетин означає „це і те“. Почнімо з двох простих форм:

    type Timestamped = { createdAt: Date };
    type Named = { name: string };
    

    Для перетину об’єкт має мати поля обох:

    type Record = Timestamped & Named;const item: Record = { name: "Invoice", createdAt: new Date() };
    

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

    Функції

    Анотації параметрів та значень повернення є основою контракту функції:

    function multiply(a: number, b: number): number {
      return a * b;
    }
    

    Необов’язкові параметри позначаються символом ?; значення за замовчуванням роблять параметр необов’язковим та визначають його тип, а параметри rest збирають будь-яку кількість аргументів у масив з визначеним типом:

    // optional parameter
    function greet(name: string, title?: string): string {
      return title ? `${title} ${name}` : name;
    }// default parameter
    function createOrder(item: string, quantity: number = 1) {
      return { item, quantity };
    }// rest parameters
    function sum(...numbers: number[]): number {
      return numbers.reduce((total, n) => total + n, 0);
    }
    

    Типи функцій

    Ви також можете описати форму самої функції, що є корисним для калебеків та об’єктів стратегій:

    type MathOperation = (a: number, b: number) => number;
    

    Функція, призначена для цього типу, отримує типи своїх параметрів з анотації, тому a та b не потребують власних анотацій:

    const subtract: MathOperation = (a, b) => a - b;
    

    Класи та модифікатори доступу

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

    class Account {
      private balance: number;
      readonly owner: string;
    

    Решта класу встановлює ці поля у конструкторі та надає методи для зміни та читання балансу; доступ до приватного поля ззовні заборонений:

      constructor(owner: string, initialBalance: number) {
        this.owner = owner;
        this.balance = initialBalance;
      }  deposit(amount: number): void {
        this.balance += amount;
      }  getBalance(): number {
        return this.balance;
      }
    }const acc = new Account("Priya", 1000);
    acc.deposit(500);
    console.log(acc.getBalance()); // 1500
    acc.balance; // Error: Property 'balance' is private
    

    Модифікатори означають:

    • public, що є значенням за замовчуванням, доступний скрізь.
    • private доступний лише всередині класу.
    • protected доступний всередині класу та його підкласів.
  • readonly поля приймають значення під час створення об’єкта та не дозволяють його зміну пізніше.
  • Пам’ятайте, що обмеження, накладені через private, діють лише на рівні компілятора; під час виконання це поле є звичайним. Якщо вам потрібна справжня приватність під час виконання, для цього існують поля # у JavaScript, про які йдеться у статті Private fields у TypeScript проти синтаксису #.

    Інтерфейси як контракти класів

    Інтерфейс може визначати, що має надавати клас:

    interface Shape {
      area(): number;
    }
    

    За допомогою implements компілятор перевіряє, чи клас виконує умови контракту, і повідомляє про відсутню методику ще до запуску коду. Конструктор також використовує параметр-властивість private radius, який одночасно оголошує та присвоює значення полю:

    class Circle implements Shape {
      constructor(private radius: number) {}  area(): number {
        return Math.PI * this.radius ** 2;
      }
    }
    

    Генерики: код, який можна використовувати знову та який зберігає свої типи

    Саме з генериками TypeScript починає відходити від формату анотованого JavaScript та стає справжнім інструментом.

    Проблема, яку вони вирішують

    Допоміжну функцію, яка повертає перший елемент будь-якого масиву, можна написати за допомогою any:

    function firstElement(arr: any[]) {
      return arr[0];
    }
    

    Вона працює, але інформація про тип втрачається під час виконання, і кожен результат має тип any:

    const num = firstElement([1, 2, 3]);   // typed as `any` — no help from the compiler
    const str = firstElement(["a", "b"]);  // also `any`
    

    Параметр типу фіксує тип елемента вхідних даних та використовує його як значення для повернення:

    function firstElement<T>(arr: T[]): T {
      return arr[0];
    }
    

    Тепер кожне викликання функції отримує точний тип результату, визначений на основі аргумента:

    const num = firstElement([1, 2, 3]);   // inferred as number
    const str = firstElement(["a", "b"]);  // inferred as string
    

    T — це змінна типу, яку TypeScript заповнює на основі даних, що ви передаєте. Ви зберігаєте гнучкість типу any та отримуєте безпеку конкретного типу. Зауважте, що з порожнім масивом arr[0] на час виконання фактично дорівнює undefined; увімкнення параметра noUncheckedIndexedAccess змушує компілятор відображати це як T | undefined.

    Генеричні інтерфейси

    Генерики також працюють з інтерфейсами. Один обгорток відповіді може описувати кожен кінцевий пункт:

    interface ApiResponse<T> {
      success: boolean;
      data: T;
    }
    

    Тип даних вантажу вказується там, де використовується обгортка:

    const userResponse: ApiResponse<{ id: number; name: string }> = {
      success: true,
      data: { id: 1, name: "Sara" },
    };
    

    Саме так багато додатків створюють свій шар API: один об’єкт ApiResponse<T>, який використовується для користувачів, продуктів, замовлень та будь-чого іншого, що повертає бекенд.

    Обмеження параметра типу

    Іноді T має гарантувати певні властивості. Опишіть цю вимогу у вигляді інтерфейсу:

    interface HasLength {
      length: number;
    }
    

    Потім обмежте параметр за допомогою extends, щоб приймалися лише типи з атрибутом length:

    function logLength<T extends HasLength>(item: T): void {
      console.log(item.length);
    }logLength("hello");       // OK — strings have .length
    logLength([1, 2, 3]);     // OK — arrays have .length
    logLength(42);             // Error: number doesn't have .length
    

    Допоміжні типи

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

    interface User {
      id: number;
      name: string;
      email: string;
      isAdmin: boolean;
    }
    

    Ви можете отримати варіанти цього типу замість того, щоб оголошувати їх заново: Partial робить кожну властивість необов’язковою, Readonly робить їх усі лише для читання, Pick зберігає обрані ключі, Omit видаляє їх, а Record створює тип словника:

    // Every property becomes optional — perfect for "update" functions
    type UserUpdate = Partial<User>;// Every property becomes read-only
    type ImmutableUser = Readonly<User>;// Pick only the fields you need
    type UserPreview = Pick<User, "id" | "name">;// Everything except the fields you list
    type PublicUser = Omit<User, "email" | "isAdmin">;// A dictionary shape: keys of one type, values of another
    type UsersById = Record<number, User>;
    

    Типовим прикладом використання є функція оновлення, яка приймає будь-яку підмножину полів:

    function updateUser(id: number, changes: Partial<User>): void {
      // merge `changes` into the stored user
    }
    

    Викликаючі передають лише те, що змінилося:

    updateUser(1, { name: "New Name" }); // no need to pass email, isAdmin, etc.
    

    Посібник блогу про вбудовані типи-засоби TypeScript детальніше розглядає весь набір таких типів.

    Enum та літеральні типи

    Enum

    Enum визначає названий набір констант:

    enum OrderStatus {
      Pending,
      Shipped,
      Delivered,
    }
    

    Потім значення посилаються через enum:

    let status: OrderStatus = OrderStatus.Shipped;
    

    За замовчуванням елементи нумеруються від 0. Натомість призначення рядкових значень значно полегшує дебаггінг логів та мережевих даних:

    enum Direction {
      Up = "UP",
      Down = "DOWN",
      Left = "LEFT",
      Right = "RIGHT",
    }
    

    Об’єднання рядкових літералів часто є простішим

    Багато команд тепер віддають перевагу об’єднанню рядкових літералів, оскільки це не створює додаткового JavaScript та забезпечує більш прогнозовану поведінку:

    type OrderStatus = "pending" | "shipped" | "delivered";
    

    Компілятор все одно відхиляє будь-які значення, що знаходяться поза цим набором:

    function updateStatus(status: OrderStatus) {
      // ...
    }updateStatus("shipped");  // OK
    updateStatus("cancelled"); // Error: not assignable to type 'OrderStatus'
    

    Є ще одна практична причина віддавати перевагу юніонам: енумерації генерують код під час виконання, тому інструменти, які лише видаляють типи, наприклад, вбудована підтримка TypeScript у Node.js, не приймають їх.

    Модулі та tsconfig.json

    Модулі

    TypeScript використовує стандартну синтаксис ES-модулів. Один файл експортує функцію:

    // math.ts
    export function add(a: number, b: number): number {
      return a + b;
    }
    

    Інший файл її імпортує:

    // app.ts
    import { add } from "./math";
    

    Найважливіші налаштування

    Сформований конфігураційний файл містить десятки опцій, але лише кілька з них впливають на основну функціональність:

    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "strict": true,
        "noImplicitAny": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "outDir": "./dist"
      }
    }
    
    • strict: true увімкнює всю сукупність суворих перевірок, включаючи strictNullChecks, який змушує явно обробляти значення null та undefined. Досвідчені розробники вважають це обов’язковим, адже без нього TypeScript виявляє значно менше справжніх помилок.
  • noImplicitAny повідомляє про помилку там, де значення інакше перейшло б на any без вашого запиту. Він вже є частиною режиму strict, тому його окреме вказування має значення лише для кращої читабельності.
  • target визначає, яка версія JavaScript буде використовуватися, а отже, наскільки сучасна синтаксис буде переписана для старіших середовищ виконання.
  • Коли ви успадковуєте кодову базу з вимкненим режимом strict, правильний підхід — увімкнути його та поступово виправляти помилки, розглядаючи по одному файлу, а не використовувати any доти, доки червоні лінії не зникнуть.

    Поширені помилки на кожному рівні

    Початківці

    • Додавання анотацій до значень, які TypeScript і так би визначив.
    • Використання any як тільки компілятор видає попередження, замість того щоб уточнити справжній тип.
  • Ігнорування помилок компілятора як непотрібних шумів, коли це насправді можливість безкоштовного перегляду коду.
  • Середній рівень

    • Оголошення interface або type для одноразової структури, яку міг би обробити інференс, що додає формальності без реальної користі.
    • Визначення масивів як змінних та їх зміна за допомогою push або splice, хоча вони мали б бути типом ReadonlyArray<T> та оновлюватися незмінно.
    • Забування про те, що об’єднання типів має бути спрощене перед тим, як стануть доступні елементи конкретного типу, та спроби подолати помилки компілятора замість того, щоб прочитати його повідомлення.

    Продвинутий рівень

    • Створення генериків без обмежень, які можуть бути чим завгодно, що тихо підриває сенс використання генериків у функціях.
    • Вимкнення режиму strict у всьому проекті, щоб приховати кілька помилок, замість того, щоб їх виправити або обмежити їхній вплив.
    • Розглядати твердження про тип, таке як value as Type, ніби воно щось підтверджує. Таке твердження нічого не перевіряє під час виконання; воно лише просить компілятор довіряти вам. Дані ззовні вашої програми, включаючи відповіді API, дані, введені користувачем, та localStorage, потребують справжньої перевірки.

    Основні висновки

    • TypeScript додає до JavaScript перевірку на етапі компіляції; типи зникають під час виконання коду, тому все одно потрібна перевірка на етапі виконання.
    • Анотуйте контракти, такі як сигнатури функцій та порожні колекції, і нехай механізм висновку бере на себе решту.
    • Використовуйте interface для об’єктів, які можна розширювати, та type для об’єднань, туплів та типів, що не є об’єктами.
    • Якомога раніше вивчіть дискриміновані об’єднання; це паттерн, який найбільш прямо запобігає помилкам у коді з великою кількістю станів.
    • Жанрові та допоміжні типи, такі як Partial, Pick, Omit та Record, перетворюють розрізнені анотації на справжню систему типів.
    • Залиште strict: true увімкненим. Сила TypeScript полягає у тому, що вона виявляє помилки, які інакше було б виявлено пізніше з більшими витратами, і це можливо лише тоді, коли перевірювач має можливість виконувати свою роботу.

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