Всеосяжний посібник з функцій-тверджень TypeScript. Дізнайтеся, як поєднати компіляцію та виконання, перевіряти дані та писати безпечніший, надійніший код на практичних прикладах.
Функції-твердження в TypeScript: повний посібник із безпеки типів під час виконання
У світі веб-розробки контракт між очікуваннями вашого коду та реальністю отриманих даних часто є крихким. TypeScript революціонізував спосіб написання JavaScript, надавши потужну статичну систему типів, яка виловлює незліченну кількість помилок ще до того, як вони потраплять у продакшн. Однак ця страхувальна сітка існує переважно на етапі компіляції. Що відбувається, коли ваш чудово типізований застосунок отримує невпорядковані, непередбачувані дані із зовнішнього світу під час виконання? Саме тут функції-твердження TypeScript стають незамінним інструментом для створення справді надійних застосунків.
Цей всеосяжний посібник занурить вас у світ функцій-тверджень. Ми розглянемо, чому вони необхідні, як створювати їх з нуля та як застосовувати їх у поширених реальних сценаріях. До кінця ви будете готові писати код, який є не тільки типізованим на етапі компіляції, але й стійким та передбачуваним під час виконання.
Великий розрив: час компіляції проти часу виконання
Щоб по-справжньому оцінити функції-твердження, ми повинні спочатку зрозуміти фундаментальну проблему, яку вони вирішують: розрив між світом компіляції TypeScript та світом виконання JavaScript.
Рай компіляції TypeScript
Коли ви пишете код на TypeScript, ви працюєте в раю для розробників. Компілятор TypeScript (tsc
) діє як пильний помічник, аналізуючи ваш код відповідно до визначених вами типів. Він перевіряє:
- Передачу неправильних типів у функції.
- Доступ до властивостей, яких не існує в об'єкті.
- Виклик змінної, яка може бути
null
абоundefined
.
Цей процес відбувається перед тим, як ваш код буде виконано. Кінцевим результатом є чистий JavaScript, позбавлений усіх анотацій типів. Уявіть TypeScript як детальний архітектурний кресленик будівлі. Він гарантує, що всі плани є надійними, виміри правильними, а структурна цілісність гарантована на папері.
Реальність виконання JavaScript
Щойно ваш TypeScript скомпільовано в JavaScript і запущено в браузері або середовищі Node.js, статичні типи зникають. Ваш код тепер працює в динамічному, непередбачуваному світі виконання. Йому доводиться мати справу з даними з джерел, які він не може контролювати, наприклад:
- Відповіді API: Бекенд-сервіс може несподівано змінити структуру своїх даних.
- Введення користувача: Дані з HTML-форм завжди розглядаються як рядок, незалежно від типу поля вводу.
- Локальне сховище: Дані, отримані з
localStorage
, завжди є рядком і потребують парсингу. - Змінні середовища: Вони часто є рядками і можуть бути взагалі відсутніми.
Використовуючи нашу аналогію, час виконання — це будівельний майданчик. Кресленик був ідеальним, але доставлені матеріали (дані) можуть бути неправильного розміру, неправильного типу або просто відсутніми. Якщо ви спробуєте будувати з цих дефектних матеріалів, ваша конструкція завалиться. Саме тут виникають помилки під час виконання, що часто призводить до збоїв і помилок на кшталт "Cannot read properties of undefined".
Зустрічайте функції-твердження: подолання розриву
Отже, як нам застосувати наш кресленик TypeScript до непередбачуваних матеріалів під час виконання? Нам потрібен механізм, який може перевіряти дані *в момент їх надходження* та підтверджувати, що вони відповідають нашим очікуванням. Саме це і роблять функції-твердження.
Що таке функція-твердження?
Функція-твердження — це особливий тип функції в TypeScript, яка виконує дві критично важливі цілі:
- Перевірка під час виконання: Вона виконує валідацію значення або умови. Якщо перевірка не вдається, вона викидає помилку, негайно зупиняючи виконання цієї гілки коду. Це запобігає поширенню недійсних даних далі у вашому застосунку.
- Звуження типів під час компіляції: Якщо валідація проходить успішно (тобто помилка не викидається), вона сигналізує компілятору TypeScript, що тип значення тепер є більш конкретним. Компілятор довіряє цьому твердженню і дозволяє вам використовувати значення як затверджений тип до кінця його області видимості.
Магія полягає в сигнатурі функції, яка використовує ключове слово asserts
. Існує дві основні форми:
asserts condition [is type]
: Ця форма стверджує, що певнаcondition
є істинною. Ви можете опціонально додатиis type
(предикат типу), щоб також звузити тип змінної.asserts this is type
: Використовується в методах класу для твердження типу контекстуthis
.
Ключовим моментом є поведінка "викидати помилку при невдачі". На відміну від простої перевірки 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>
asserts
: Це спеціальне ключове слово TypeScript, яке перетворює цю функцію на функцію-твердження.value
: Це посилається на перший параметр функції (у нашому випадку, змінну з іменем `value`). Воно вказує TypeScript, тип якої змінної слід звузити.is NonNullable<T>
: Це предикат типу. Він повідомляє компілятору, що якщо функція не викидає помилку, тип `value` тепер єNonNullable<T>
. Утилітарний типNonNullable
у TypeScript видаляєnull
таundefined
з типу.
Практичні випадки використання функцій-тверджень
Тепер, коли ми розуміємо основи, розгляньмо, як застосовувати функції-твердження для вирішення поширених, реальних проблем. Вони найпотужніші на межах вашого застосунку, де зовнішні, нетипізовані дані потрапляють у вашу систему.
Сценарій 1: Валідація відповідей API
Це, мабуть, найважливіший сценарій використання. Дані із запиту fetch
за своєю суттю є ненадійними. TypeScript правильно типізує результат `response.json()` як `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). |
Ключова настанова
Запитайте себе: "Що має статися, якщо ця перевірка не вдасться?"
- Якщо є законний альтернативний шлях (наприклад, показати кнопку входу, якщо користувач не автентифікований), використовуйте захисник типу з блоком
if/else
. - Якщо невдала перевірка означає, що ваша програма перебуває в недійсному стані і не може безпечно продовжувати роботу, використовуйте функцію-твердження.
- Якщо ви перевизначаєте компілятор без перевірки під час виконання, ви використовуєте приведення типів. Будьте дуже обережні.
Просунуті патерни та найкращі практики
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 — це більше, ніж просто нішева функція; це фундаментальний інструмент для написання надійних, готових до продакшену застосунків. Вони дають вам змогу подолати критичний розрив між теорією часу компіляції та реальністю часу виконання.
Використовуючи функції-твердження, ви можете:
- Забезпечувати інваріанти: Формально оголошувати умови, які повинні виконуватися, роблячи припущення вашого коду явними.
- Падати швидко і голосно: Виловлювати проблеми з цілісністю даних біля їх джерела, запобігаючи виникненню ледь помітних і важких для налагодження помилок пізніше.
- Покращувати читабельність коду: Прибирати вкладені
if
перевірки та приведення типів, що призводить до чистішої, більш лінійної та самодокументованої бізнес-логіки. - Підвищувати впевненість: Писати код з упевненістю, що ваші типи — це не просто пропозиції для компілятора, а правила, які активно застосовуються під час виконання коду.
Наступного разу, коли ви отримуватимете дані з API, читатимете файл конфігурації або оброблятимете введення користувача, не просто приводьте тип і сподівайтеся на краще. Стверджуйте його. Побудуйте ворота безпеки на межі вашої системи. Ваше майбутнє "я" — і ваша команда — подякують вам за написаний надійний, передбачуваний та стійкий код.