Главная / Статьи

Замена `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 просто означает «тип этого значения пока неизвестен».

На самом деле это сообщает компилятору: «Прекратите проверку этой переменной и доверьтесь тому, что всё, что я делаю с ней дальше, будет корректно».

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, генерики и дискриминированные союзы, ваш код начинает сам себя объяснять, количество ошибок во время выполнения резко снижается, а компилятор наконец начинает выполнять свою функцию защиты, для которой и был создан.