Українська

Всеосяжний посібник з функцій-тверджень TypeScript. Дізнайтеся, як поєднати компіляцію та виконання, перевіряти дані та писати безпечніший, надійніший код на практичних прикладах.

Функції-твердження в TypeScript: повний посібник із безпеки типів під час виконання

У світі веб-розробки контракт між очікуваннями вашого коду та реальністю отриманих даних часто є крихким. TypeScript революціонізував спосіб написання JavaScript, надавши потужну статичну систему типів, яка виловлює незліченну кількість помилок ще до того, як вони потраплять у продакшн. Однак ця страхувальна сітка існує переважно на етапі компіляції. Що відбувається, коли ваш чудово типізований застосунок отримує невпорядковані, непередбачувані дані із зовнішнього світу під час виконання? Саме тут функції-твердження TypeScript стають незамінним інструментом для створення справді надійних застосунків.

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

Великий розрив: час компіляції проти часу виконання

Щоб по-справжньому оцінити функції-твердження, ми повинні спочатку зрозуміти фундаментальну проблему, яку вони вирішують: розрив між світом компіляції TypeScript та світом виконання JavaScript.

Рай компіляції TypeScript

Коли ви пишете код на TypeScript, ви працюєте в раю для розробників. Компілятор TypeScript (tsc) діє як пильний помічник, аналізуючи ваш код відповідно до визначених вами типів. Він перевіряє:

Цей процес відбувається перед тим, як ваш код буде виконано. Кінцевим результатом є чистий JavaScript, позбавлений усіх анотацій типів. Уявіть TypeScript як детальний архітектурний кресленик будівлі. Він гарантує, що всі плани є надійними, виміри правильними, а структурна цілісність гарантована на папері.

Реальність виконання JavaScript

Щойно ваш TypeScript скомпільовано в JavaScript і запущено в браузері або середовищі Node.js, статичні типи зникають. Ваш код тепер працює в динамічному, непередбачуваному світі виконання. Йому доводиться мати справу з даними з джерел, які він не може контролювати, наприклад:

Використовуючи нашу аналогію, час виконання — це будівельний майданчик. Кресленик був ідеальним, але доставлені матеріали (дані) можуть бути неправильного розміру, неправильного типу або просто відсутніми. Якщо ви спробуєте будувати з цих дефектних матеріалів, ваша конструкція завалиться. Саме тут виникають помилки під час виконання, що часто призводить до збоїв і помилок на кшталт "Cannot read properties of undefined".

Зустрічайте функції-твердження: подолання розриву

Отже, як нам застосувати наш кресленик TypeScript до непередбачуваних матеріалів під час виконання? Нам потрібен механізм, який може перевіряти дані *в момент їх надходження* та підтверджувати, що вони відповідають нашим очікуванням. Саме це і роблять функції-твердження.

Що таке функція-твердження?

Функція-твердження — це особливий тип функції в TypeScript, яка виконує дві критично важливі цілі:

  1. Перевірка під час виконання: Вона виконує валідацію значення або умови. Якщо перевірка не вдається, вона викидає помилку, негайно зупиняючи виконання цієї гілки коду. Це запобігає поширенню недійсних даних далі у вашому застосунку.
  2. Звуження типів під час компіляції: Якщо валідація проходить успішно (тобто помилка не викидається), вона сигналізує компілятору TypeScript, що тип значення тепер є більш конкретним. Компілятор довіряє цьому твердженню і дозволяє вам використовувати значення як затверджений тип до кінця його області видимості.

Магія полягає в сигнатурі функції, яка використовує ключове слово asserts. Існує дві основні форми:

Ключовим моментом є поведінка "викидати помилку при невдачі". На відміну від простої перевірки if, твердження заявляє: "Ця умова повинна бути істинною, щоб програма могла продовжити роботу. Якщо ні, це винятковий стан, і ми повинні негайно зупинитися."

Створення вашої першої функції-твердження: практичний приклад

Почнемо з однієї з найпоширеніших проблем у JavaScript та TypeScript: робота з потенційно null або undefined значеннями.

Проблема: небажані null

Уявіть собі функцію, яка приймає необов'язковий об'єкт користувача і хоче вивести в лог його ім'я. Суворі перевірки на null у TypeScript правильно попередять нас про потенційну помилку.


interface User {
  name: string;
  email: string;
}

function logUserName(user: User | undefined) {
  // 🚨 Помилка TypeScript: 'user' може бути 'undefined'.
  console.log(user.name.toUpperCase()); 
}

Стандартний спосіб це виправити — за допомогою перевірки if:


function logUserName(user: User | undefined) {
  if (user) {
    // Усередині цього блоку TypeScript знає, що 'user' має тип 'User'.
    console.log(user.name.toUpperCase());
  } else {
    console.error('User is not provided.');
  }
}

Це працює, але що, якщо user, який є undefined, є невиправною помилкою в цьому контексті? Ми не хочемо, щоб функція продовжувала роботу мовчки. Ми хочемо, щоб вона голосно повідомляла про збій. Це призводить до повторюваних захисних умов.

Рішення: функція-твердження `assertIsDefined`

Створімо функцію-твердження для повторного використання, щоб елегантно обробляти цей патерн.


// Наша функція-твердження для повторного використання
function assertIsDefined<T>(value: T, message: string = "Value is not defined"): asserts value is NonNullable<T> {
  if (value === undefined || value === null) {
    throw new Error(message);
  }
}

// Використаймо її!
interface User {
  name: string;
  email: string;
}

function logUserName(user: User | undefined) {
  assertIsDefined(user, "User object must be provided to log name.");

  // Немає помилки! TypeScript тепер знає, що 'user' має тип 'User'.
  // Тип було звужено з 'User | undefined' до 'User'.
  console.log(user.name.toUpperCase());
}

// Приклад використання:
const validUser = { name: 'Alice', email: 'alice@example.com' };
logUserName(validUser); // Виводить "ALICE"

const invalidUser = undefined;
try {
  logUserName(invalidUser); // Викидає помилку: "User object must be provided to log name."
} catch (error) {
  console.error(error.message);
}

Розбираємо сигнатуру твердження

Розберемо сигнатуру: asserts value is NonNullable<T>

Практичні випадки використання функцій-тверджень

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

Сценарій 1: Валідація відповідей API

Це, мабуть, найважливіший сценарій використання. Дані із запиту fetch за своєю суттю є ненадійними. TypeScript правильно типізує результат `response.json()` як `Promise` або `Promise`, змушуючи вас його валідувати.

Сценарій

Ми отримуємо дані користувача з API. Ми очікуємо, що вони відповідатимуть нашому інтерфейсу `User`, але не можемо бути впевнені.


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

// Звичайний захисник типу (повертає boolean)
function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data && typeof (data as any).id === 'number' &&
    'name' in data && typeof (data as any).name === 'string' &&
    'email' in data && typeof (data as any).email === 'string'
  );
}

// Наша нова функція-твердження
function assertIsUser(data: unknown): asserts data is User {
  if (!isUser(data)) {
    throw new TypeError('Invalid User data received from API.');
  }
}

async function fetchAndProcessUser(userId: number) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data: unknown = await response.json();

  // Стверджуємо форму даних на межі системи
  assertIsUser(data);

  // З цього моменту 'data' безпечно типізовано як 'User'.
  // Більше не потрібні перевірки 'if' або приведення типів!
  console.log(`Processing user: ${data.name.toUpperCase()} (${data.email})`);
}

fetchAndProcessUser(1);

Чому це потужно: Викликаючи `assertIsUser(data)` одразу після отримання відповіді, ми створюємо "ворота безпеки". Будь-який код, що йде далі, може впевнено розглядати `data` як `User`. Це відокремлює логіку валідації від бізнес-логіки, що призводить до набагато чистішого та читабельнішого коду.

Сценарій 2: Гарантування наявності змінних середовища

Серверні застосунки (наприклад, у Node.js) значною мірою покладаються на змінні середовища для конфігурації. Доступ до `process.env.MY_VAR` дає тип `string | undefined`. Це змушує вас перевіряти його наявність скрізь, де ви його використовуєте, що є виснажливим і схильним до помилок.

Сценарій

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


// У файлі утиліт, наприклад, 'config.ts'

export function getEnvVar(key: string): string {
  const value = process.env[key];

  if (value === undefined) {
    throw new Error(`FATAL: Environment variable ${key} is not set.`);
  }

  return value;
}

// Більш потужна версія з використанням тверджень
function assertEnvVar(key: string): asserts key is keyof NodeJS.ProcessEnv {
  if (process.env[key] === undefined) {
    throw new Error(`FATAL: Environment variable ${key} is not set.`);
  }
}

// У точці входу вашого застосунку, наприклад, 'index.ts'

function startServer() {
  // Виконуємо всі перевірки під час запуску
  assertEnvVar('API_KEY');
  assertEnvVar('DATABASE_URL');

  const apiKey = process.env.API_KEY;
  const dbUrl = process.env.DATABASE_URL;

  // TypeScript тепер знає, що apiKey та dbUrl є рядками, а не 'string | undefined'.
  // Ваш застосунок гарантовано має необхідну конфігурацію.
  console.log('API Key length:', apiKey.length);
  console.log('Connecting to DB:', dbUrl.toLowerCase());

  // ... решта логіки запуску сервера
}

startServer();

Чому це потужно: Цей патерн називається "fail-fast" (швидке падіння). Ви валідуєте всі критичні конфігурації один раз на самому початку життєвого циклу вашого застосунку. Якщо виникає проблема, він негайно завершує роботу з детальним описом помилки, що набагато легше для налагодження, ніж таємничий збій, який трапляється пізніше, коли нарешті використовується відсутня змінна.

Сценарій 3: Робота з DOM

Коли ви робите запит до DOM, наприклад, за допомогою `document.querySelector`, результатом є `Element | null`. Якщо ви впевнені, що елемент існує (наприклад, головний кореневий `div` застосунку), постійно перевіряти на `null` може бути обтяжливо.

Сценарій

У нас є HTML-файл з `

`, і наш скрипт повинен додати до нього контент. Ми знаємо, що він існує.


// Повторне використання нашого загального твердження
function assertIsDefined<T>(value: T, message: string = "Value is not defined"): asserts value is NonNullable<T> {
  if (value === undefined || value === null) {
    throw new Error(message);
  }
}

// Більш конкретне твердження для елементів DOM
function assertQuerySelector<T extends Element>(selector: string, constructor?: new () => T): T {
  const element = document.querySelector(selector);
  assertIsDefined(element, `FATAL: Element with selector '${selector}' not found in the DOM.`);

  // Опціонально: перевіряємо, чи це правильний тип елемента
  if (constructor && !(element instanceof constructor)) {
    throw new TypeError(`Element '${selector}' is not an instance of ${constructor.name}`);
  }

  return element as T;
}

// Використання
const appRoot = document.querySelector('#app-root');
assertIsDefined(appRoot, 'Could not find the main application root element.');

// Після твердження appRoot має тип 'Element', а не 'Element | null'.
appRoot.innerHTML = '

Hello, World!

'; // Використання більш конкретного хелпера const submitButton = assertQuerySelector<HTMLButtonElement>('#submit-btn', HTMLButtonElement); // 'submitButton' тепер правильно типізовано як HTMLButtonElement submitButton.disabled = true;

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

Функції-твердження проти альтернатив

Важливо знати, коли використовувати функцію-твердження, а коли — інші техніки звуження типів, такі як захисники типів або приведення типів.

Техніка Синтаксис Поведінка при невдачі Найкраще підходить для
Захисники типів value is Type Повертає false Керування потоком (if/else). Коли існує валідний, альтернативний шлях коду для "негативного" випадку. Наприклад, "Якщо це рядок, обробити його; інакше, використати значення за замовчуванням."
Функції-твердження asserts value is Type Викидає Error Забезпечення інваріантів. Коли умова повинна бути істинною, щоб програма могла коректно продовжити роботу. "Негативний" шлях є невиправною помилкою. Наприклад, "Відповідь API повинна бути об'єктом User."
Приведення типів value as Type Немає ефекту під час виконання Рідкісні випадки, коли ви, розробник, знаєте більше, ніж компілятор, і вже виконали необхідні перевірки. Це не дає жодної безпеки під час виконання і повинно використовуватися з обережністю. Надмірне використання є "запахом коду" (code smell).

Ключова настанова

Запитайте себе: "Що має статися, якщо ця перевірка не вдасться?"

Просунуті патерни та найкращі практики

1. Створіть централізовану бібліотеку тверджень

Не розкидайте функції-твердження по всій вашій кодовій базі. Централізуйте їх у спеціальному файлі утиліт, наприклад, src/utils/assertions.ts. Це сприяє повторному використанню, узгодженості та робить вашу логіку валідації легкою для пошуку та тестування.


// src/utils/assertions.ts

export function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

export function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
  assert(value !== null && value !== undefined, 'This value must be defined.');
}

export function assertIsString(value: unknown): asserts value is string {
  assert(typeof value === 'string', 'This value must be a string.');
}

// ... і так далі.

2. Викидайте змістовні помилки

Повідомлення про помилку від невдалого твердження — це ваша перша підказка під час налагодження. Зробіть його корисним! Загальне повідомлення типу "Твердження не виконалося" не допомагає. Натомість надайте контекст:


function assertIsUser(data: unknown): asserts data is User {
  if (!isUser(data)) {
    // Погано: throw new Error('Недійсні дані');
    // Добре:
    throw new TypeError(`Expected data to be a User object, but received ${JSON.stringify(data)}`);
  }
}

3. Пам'ятайте про продуктивність

Функції-твердження — це перевірки під час виконання, що означає, що вони споживають цикли процесора. Це цілком прийнятно і бажано на межах вашого застосунку (вхідні API-запити, завантаження конфігурації). Однак уникайте розміщення складних тверджень у критичних для продуктивності ділянках коду, таких як щільний цикл, що виконується тисячі разів на секунду. Використовуйте їх там, де вартість перевірки незначна порівняно з операцією, що виконується (наприклад, мережевий запит).

Висновок: пишіть код з упевненістю

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

Використовуючи функції-твердження, ви можете:

Наступного разу, коли ви отримуватимете дані з API, читатимете файл конфігурації або оброблятимете введення користувача, не просто приводьте тип і сподівайтеся на краще. Стверджуйте його. Побудуйте ворота безпеки на межі вашої системи. Ваше майбутнє "я" — і ваша команда — подякують вам за написаний надійний, передбачуваний та стійкий код.