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

Заміна `any` у TypeScript: шість безпечних за типом патернів для поширених випадків

Дізнайтеся про практичні, безпечні за типом альтернативи `any` у TypeScript — зокрема unknown, генеріки, дискриміновані союзи та повні перевірки — для роботи з непередбачуваними даними.

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

Джерела: Stop Using ‘any’ in TypeScript — Here’s the Right Fix Every Time

Невелика зміна в дисципліні, яка захистить вас від несподіванок під час розробки

Кожен розробник TypeScript стикався з цим. Вже пізня п’ятнична полудень, дивна помилка компілятора затримує ваш pull request, і після двадцяти хвилин боротьби з вкладеними генериками та типами об’єднання все, чого ви хочете — це оприлюднити код та закрити ноутбук.

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

const payload: any = response.data;

Червона підкреслення зникає. Процес компіляції стає зеленим. Усе виглядає нормально.

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

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

Що насправді означає any (і чому це призводить до проблем)

Багато розробників вважають, що any означає просто „тип цього значення ще невідомий“.

Те, що це насправді передає компілятору, — це: «Припиніть перевірку цієї змінної та довіряйте тому, що все, що я роблю з нею далі, є правильним».

let user: any = { name: "Alice" };

// TypeScript allows all of these without a single warning:
user.authenticate();       // Runtime crash: user.authenticate is not a function
user.address.postalCode;   // Runtime crash: Cannot read properties of undefined
user();                    // Runtime crash: user is not a function

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

У наступних розділах розглядаються правильні альтернативи для кожного поширеного випадку, коли тип any починає з’являтися.

Виправлення 1: Коли тип дійсно ще невідомий (unknown)

Ви отримуєте дані з зовнішнього API, парсите рядок JSON, отриманий з локального сховища, або обробляєте необроблений вміст від webhook. У таких випадках немає можливості дізнатися точну структуру даних до того, як програма фактично запуститься.

function parseApiResponse(jsonString: string): any {
  return JSON.parse(jsonString);
}

const data = parseApiResponse('{"id": 101}');
console.log(data.profile.avatar); // Compiles fine, crashes immediately if profile is missing

Використовуйте тип unknown у поєднанні з type guard.

unknown функціонує як більш безпечна версія типу any. Він повідомляє компілятору: „Тип цього значення ще не визначений, але вам заборонено отримувати доступ до його властивостей, викликати методи чи іншим чином працювати з ним, поки ви не доведете, що воно насправді є.“

interface UserProfile {
  id: number;
  name: string;
}

// Custom type guard
function isUserProfile(value: unknown): value is UserProfile {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as Record<string, unknown>).id === "number" &&
    "name" in value &&
    typeof (value as Record<string, unknown>).name === "string"
  );
}

function parseApiResponse(jsonString: string): unknown {
  return JSON.parse(jsonString);
}

const rawData = parseApiResponse('{"id": 101, "name": "Sarah"}');

// rawData.name; // Error: Object is of type 'unknown'.

if (isUserProfile(rawData)) {
  // Inside this block, TypeScript knows rawData is definitely UserProfile
  console.log(rawData.name); // Fully typed, autocompleted, and safe!
}

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

Рішення 2: Коли одній функції потрібно підтримувати кілька типів (генерики)

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

Проблемний підхід:

function getFirstItem(items: any[]): any {
  return items[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers); // firstNum is typed as 'any'
firstNum.toUpperCase(); // Compiles! Crashes at runtime because it's a number.

Використовуйте генерики (синтаксис <T>).

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

function getFirstItem<T>(items: T[]): T | undefined {
  return items[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers);
// TypeScript automatically infers firstNum as: number | undefined

const names = ["Alice", "Bob"];
const firstName = getFirstItem(names);
// TypeScript automatically infers firstName as: string | undefined

Ви також можете обмежити можливі варіанти генерика, використовуючи extends, щоб переконатися, що тип містить певні обов’язкові властивості:

interface HasId {
  id: string | number;
}

function printId<T extends HasId>(entity: T): void {
  console.log(entity.id);
}

Виправлення 3: Обробка помилок у блоках try / catch

Ви пишете логіку всередині блоку catch, щоб вирішити проблему, яка виникла.

Проблематичний підхід:

try {
  fetchUserData();
} catch (err: any) {
  // Risky: In JavaScript, anything can be thrown (strings, numbers, null)
  console.error(err.message.toUpperCase());
}

Ризик полягає у тому, що JavaScript дозволяє кидати буквально все — рядки, числа, null, звичайні об’єкти, а не лише екземпляри Error. Якщо якась бібліотека чи несподівана помилка кидає звичайний рядок, наприклад throw "Network timeout", тоді err.message буде дорівнювати undefined, і виклик методу на кшталт .toUpperCase() може спричинити нову, неперетворену помилку прямо в коді, призначеному для її обробки.

Правильне рішення: Визначте спійману помилку як unknown та звузьте її за допомогою instanceof Error.

try {
  fetchUserData();
} catch (err: unknown) {
  if (err instanceof Error) {
    // TypeScript safely knows 'err' has a .message property
    console.error(`Error: ${err.message}`);
  } else {
    // Fallback for non-standard thrown objects
    console.error("An unexpected error occurred", String(err));
  }
}

Рішення 4: Динамічні об’єкти та пошук за ключами

Вам потрібен об’єкт, який буде поводитися як карта чи словник, з ключами, відомими лише під час виконання програми.

Проблематичний підхід:

const userRolePermissions: any = {};
userRolePermissions["admin"] = ["read", "write", "delete"];

Правильне рішення: Використовуйте Record<Keys, Values>, або альтернативно індексовану сигнатуру.

type Role = "admin" | "editor" | "viewer";
type Permission = "read" | "write" | "delete";

// A strictly defined key-value mapping
const rolePermissions: Record<Role, Permission[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// If the keys can be arbitrary dynamic strings:
const sessionCache: Record<string, unknown> = {};

Використання Record<string, unknown> дозволяє перевіряти структуру об’єкта, водночас змушуючи кожного, хто ним користується, перевіряти, що саме є значенням, перед тим як з ним працювати.

Рішення 5: Визначення форми, яка змінюється (дискриміновані союзи)

Процес проходить через різні фази — наприклад idle, loading, success та error — і кожна фаза містить свій тип даних.

Проблематичний підхід:

interface RequestState {
  status: any;
  data?: any;
  error?: any;
}

Моделювання цього за допомогою єдиної вільної інтерфейсу означає, що кожна властивість стає необов’язковою, що змушує вас складати ланцюжки ?. у всьому коді. Ще гірше те, що ніщо не заважає потрапити у безглузде стан, коли одночасно заповнюються як data, так і error.

Правильне рішення: використовувати дискриміновані союзи.

Надайте кожному можливому стану спільне літеральне поле — зазвичай під назвою status або type — яке TypeScript може використовувати для розрізнення варіантів:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error };

function renderUI(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Click start";
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript automatically knows state.data exists here!
      return `Loaded ${state.data.length} items`;
    case "error":
      // TypeScript automatically knows state.error exists here!
      return `Failed: ${state.error.message}`;
  }
}

Рішення 6: Повне перевіряння за допомогою never

Ви хочете, щоб сам компілятор попереджав про це, коли колега додає новий випадок до типу союзу, але забуває оновити кожен ланцюжок switch або if/else, який ним керується.

Проблематичний підхід: Використання універсального рішення default: return any або повне ігнорування додаткового випадку.

Правильне рішення: Призначити все, що залишилося, типу never.

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

type NotificationType = "email" | "sms" | "push";

function sendNotification(type: NotificationType) {
  switch (type) {
    case "email":
      // Handle email
      break;
    case "sms":
      // Handle SMS
      break;
    case "push":
      // Handle push notification
      break;
    default: {
      // If someone adds "in_app" to NotificationType and forgets to update this function,
      // TypeScript will raise a compile-time error right here.
      const _exhaustiveCheck: never = type;
      return _exhaustiveCheck;
    }
  }
}

Швидкий посібник: заміна any

Як забезпечити єдині стандарти в команді

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

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

У файлі tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true
  }
}

У конфігурації ESLint (.eslintrc.json):

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error"
  }
}

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