Замена `any` в TypeScript: шесть безопасных по типу паттернов для распространённых случаев
Узнайте практические, безопасные с точки зрения типов альтернативы типу `any` в TypeScript — включая тип unknown, генерики, дискриминированные союзы и полные проверки — для обработки непредсказуемых данных.
Источники: 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, генерики и дискриминированные союзы, ваш код начинает сам себя объяснять, количество ошибок во время выполнения резко снижается, а компилятор наконец начинает выполнять свою функцию защиты, для которой и был создан.