Головна / Статті

Шість технік TypeScript, які перетворюють типи на ефективний засіб запобігання помилкам

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

Видавець: REACTAPP.TOP7 хв читання1627 слів

Джерела: TypeScript Can Already Do This. Almost Nobody Uses It.

Проєкт може вказати TypeScript у package.json, оголосити інтерфейси з десятком необов’язкових полів, але все одно отримати помилку „cannot read properties of undefined“. Наявність встановленого компілятора — це не те саме, що його функціональна робота для вас, а додавання : string до кожної змінної саме по собі мало допомагає. Шість методів, наведених нижче, — це те, що використовують досвідчені команди, щоб надати перевірці типів реальної сили; кожен з них супроводжується фрагментом коду та поясненням його ефективності.

Перевіряйте об’єкти за допомогою satisfies замість анотацій

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

const config: Record<string, number> = {
  timeout: 3000,
  retries: 3,
};
config.timeout.toFixed(2); // fine

Цей код компілюється, але анотація замінює те, що б інферував TypeScript. Тип змінної тепер є точно Record<string, number>, тож компілятор більше не знає, які ключі насправді має об’єкт. config.timeout — це просто „якесь число під якимось рядковим ключем“, помилка написання, така як config.timout, приймається без зауважень, а з увімкненим noUncheckedIndexedAccess кожне зчитуване властивість стає number | undefined.

Оператор satisfies, доступний з TypeScript 4.9, перевіряє об’єкт за типом, не замінюючи інферований тип:

const config = {
  timeout: 3000,
  retries: 3,
} satisfies Record<string, number>;

config.timeout; // TypeScript still knows this is literally 3000

Ви отримуєте можливість перевірки (значення у вигляді рядка буде відхилено), а також зберігаєте точну структуру об’єкта: TypeScript знає, що об’єкт має саме поля timeout та retries, а неправильно написані ключі спричиняють помилки. Одне уточнення щодо поширеної думки, включаючи коментар у фрагменті коду: саме слово satisfies не зберігає тип значення timeout як літерального типу 3000. Властивості змінного об’єкта все одно перетворюються на тип number. Якщо вам потрібні також літеральні значення, поєднайте обидва оператори у вигляді as const satisfies Record<string, number> — це зробить об’єкт лише для читання та збереже всі літерали. Багато розробників досі використовують цю анотацію лише тому, що протягом багатьох років це був єдиний варіант.

Варіанти моделей як теговані об’єднання, а не опційні поля

Коли значення може мати кілька форм, спокусливим скороченням є зробити кожне поле необов’язковим:

interface ApiResponse {
  data?: User[];
  error?: string;
  loading?: boolean;
}

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

Дискримінована унія (тегована унія) кодує це правило. Кожен варіант містить літеральний тег status та лише ті поля, які належать до нього:

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />; // data exists, guaranteed
    case 'error':
      return <ErrorBanner message={response.error} />;
  }
}

Усередині кожного case TypeScript звужує значення response до відповідної версії. До response.data можна отримати доступ лише у гілці success, оскільки інші версії взагалі не мають властивості data. Ціла категорія помилок типу „Чи визначено це тут?“ зникає ще до запуску коду, і такий самий підхід працює для дій редуктора, станів форм та результатів API.

Змусити відсутні кейси призводити до невдачі під час збірки за допомогою never

У тегованих об’єднаннях залишається одна проблема. Припустимо, через кілька місяців хтось додасть статус 'forbidden'. Ніщо не змушує оновлювати кожен switch над типом ApiResponse, тож новий кейс просто проходить без ефекту, що призводить до помилки у продакшені.

Перевірка на повноту закриває цю прогалину. Гілка default призначає звужене значення змінній типу never:

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />;
    case 'error':
      return <ErrorBanner message={response.error} />;
    default:
      const exhaustiveCheck: never = response;
      throw new Error(`Unhandled status: ${exhaustiveCheck}`);
  }
}

Коли оброблено всі варіанти, для response у гілці default більше нічого не залишається, тому її тип — never, і присвоєння компілюється. Якщо додати 'forbidden' без відповідного case, то response там стане { status: 'forbidden' }, яке не можна присвоїти типу never, тож компіляція зазнає невдачі, і проблема вказує безпосередньо на забуту гілку. Оператор throw також захищає програму під час виконання, якщо все одно надходять неочікувані дані. Обгортання тіла гілки default у дужки допомагає інструментам перевірки коду, які фіксують оголошення всередині клозу case.

Використовуйте unknown на межах вашого додатку

any вимикає перевірку типу. unknown залишає її увімкненою та змушує вас довести, що таке значення, перш ніж використати його. Саме таку поведінку потрібно, коли надходять ненадійні дані: відповіді API, результати JSON.parse, дані webhook.

Тут допоміжна функція fetch навмисно повертає Promise<unknown>. Пряме зчитування .name спричиняє помилку компіляції, і це стає допустимим лише всередині типового захисника:

async function fetchUser(id: string): Promise<unknown> {
  const res = await fetch(`/api/user/${id}`);
  return res.json();
}

const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
  raw.name; // fine, narrowed to User inside the guard
}

isUser — це типовий захисник, визначений користувачем, із сигнатурою function isUser(x: unknown): x is User; його тіло має фактично перевіряти структуру даних у час виконання. За допомогою типу unknown компілятор забороняє доступ до властивостей, поки хтось не перевірить дані. За допомогою типу any перевірка є необов’язковою, і часто її пропускають, поки до компонента, який припускав наявність певного поля, не надійде некоректна відповідь. Для більших об’ємів даних бібліотеки схем, такі як Zod, можуть генерувати як перевірку у час виконання, так і відповідний тип. Більше прикладів цього описано у шести типобезпечних альтернативах типу any.

Отримуйте типи з коду замість того, щоб підтримувати копії

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

function createOrderPayload(order: Order) {
  return {
    id: order.id,
    total: order.total,
    lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
  };
}

type OrderPayload = ReturnType<typeof createOrderPayload>;

ReturnType<typeof createOrderPayload> визначає тип значення, яке повертає функція. Якщо змінити саму функцію, OrderPayload автоматично оновиться у всіх місцях його використання. На відміну від цього, інтерфейс, який підтримується вручну, поступово виходить з ладу: після кількох оптимізацій він може стверджувати, що існує певне поле, яке функція вже давно не повертає, а компілятор продовжуватиме довіряти цьому застарілому інтерфейсу.

Та сама ідея застосовується до компонентів з бібліотек, які не належать вам. React.ComponentProps<typeof Button> повертає саме ті властивості, які приймає Button від стороннього розробника, взяті з його власних визначень типів, тому немає потреби оновлювати якусь «тіньову» копію, коли бібліотека додає нову властивість. Подібні інструменти, такі як Parameters, Awaited та типи з індексованим доступом (OrderPayload['lineItems'][number]), розширюють цей підхід; детальніше дивіться у вбудованих у TypeScript інструментальних типах.

Примітивні типи бренду, щоб ID не можна було замінити

За замовчуванням як UserId, так і OrderId є звичайними рядками, тому передача ідентифікатора замовлення функції, яка очікує ідентифікатор користувача, компілюється без проблем. У такому разі запит просто не повертає жодних даних, і комусь доводиться витрачати час на з’ясування причини.

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

type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId

Властивість __brand ніколи не існує під час виконання; вона існує лише в системі типів, що достатньо для того, щоб TypeScript розглядав ці два типи як окремі. Значення створюються за допомогою невеликого конструктора або функції перевірки (у прикладі це замінюється оператором as OrderId), і з того моменту кожна випадкова плутанина призводить до помилки компіляції без жодних витрат під час виконання. Зберігайте оператори перетворення всередині цих кількох конструкторів, щоб термін „бренд“ дійсно означав „це значення було перевірено“.

Підсумок

Для всього цього не потрібна нова фреймворк чи бібліотека; це той самий TypeScript, який у вас вже є, використаний свідомо:

  • satisfies перевіряє дані, не видаляючи визначений раніше формат; додайте as const, коли важливі літерали.
  • Теговані об’єднання роблять неможливі стани непредставленими.
  • Перевірка типу never перетворює забутий випадок на помилку під час компіляції.
  • Значення unknown на межах змушує проводити перевірку перед використанням.
  • Похідні типи залишаються синхронізованими з кодом, який генерує значення.
  • Бренди дозволяють тримати семантично різні рядки окремо.
  • Виберіть одну з цих технік та застосуйте її у своєму наступному pull request. Кожна з них переміщує певний клас помилок з часу виконання на час компіляції, що і є практичною різницею між наявністю TypeScript та його захистом вашого коду.