any, unknown и never: в чём разница

Вопрос с собеседования: в чём разница между этими двумя функциями и почему вторая «безопаснее», хотя обе принимают вообще любое значение?

function processAny(data: any) {
  console.log(data.toUpperCase()); // компилируется без единой жалобы
}

function processUnknown(data: unknown) {
  console.log(data.toUpperCase()); // ОШИБКА КОМПИЛЯЦИИ
}

Вывод (компилятор):

processUnknown: Object is of type 'unknown'.ts(18046)

Первая функция спокойно проходит проверку типов, а на рантайме упадёт, если ей передать число: processAny(42) вызовет 42.toUpperCase(), а у чисел такого метода нет. Вторая функция вообще не даст себя скомпилировать, пока ты не докажешь компилятору, что data — строка. И вот это различие — ядро вопроса.

any — это буквально способ сказать компилятору TypeScript: «отключись, не проверяй это значение». Формально any тоже тип, но он ведёт себя как дыра в системе типов: любое значение можно присвоить переменной типа any, и саму переменную any можно присвоить куда угодно, и с ней можно делать любые операции — вызывать методы, обращаться к полям, складывать со строками — компилятор всё пропустит молча. По сути ты выключаешь ради этой переменной весь смысл TypeScript и возвращаешься в обычный JavaScript, только без предупреждения об этом в коде.

unknown появился в TypeScript 3.0 именно как безопасная альтернатива. Он тоже означает «значение любого типа», но с одним принципиальным ограничением: пока ты не сузил тип (не доказал компилятору, что перед ним конкретно), с значением типа unknown нельзя делать вообще ничего — ни вызывать методы, ни обращаться к полям, ни использовать в арифметике. Единственное, что доступно сразу, — это проверки типа: typeof, instanceof, сравнения с null и подобные. Только после такой проверки TypeScript «открывает доступ» к операциям, которые уместны для доказанного типа.

function processUnknownFixed(data: unknown) {
  if (typeof data === "string") {
    console.log(data.toUpperCase()); // ОК: тип сужен до string
  } else {
    console.log("Это не строка");
  }
}

processUnknownFixed("привет");
processUnknownFixed(42);

Вывод:

ПРИВЕТ
Это не строка

Практический смысл: unknown заставляет тебя (или того, кто вызывает твою функцию) явно разобраться, что за значение пришло, прежде чем с ним что-то делать. Это особенно ценно на границах программы — там, где данные приходят снаружи и их тип заранее неизвестен: ответ от сервера после fetch, результат JSON.parse, данные из формы, аргументы в catch у ошибки. Именно поэтому в современном TypeScript тип пойманной ошибки в catch (e) по умолчанию — unknown, а не any: раньше можно было писать e.message и не думать, а теперь компилятор сначала попросит убедиться, что e — действительно объект ошибки.

Третий тип из вопроса — never — стоит особняком: это тип значения, которого не существует в принципе. Не «пустое значение», а «значение никогда не будет присвоено». Он появляется в двух ситуациях. Первая — функция никогда не завершается нормально: либо всегда бросает исключение, либо уходит в бесконечный цикл.

function fail(message: string): never {
  throw new Error(message);
}

function getStatus(code: number): string {
  if (code === 200) return "OK";
  if (code === 404) return "Not Found";
  return fail("неизвестный код"); // never совместим с любым возвращаемым типом
}

Вторая, более важная для собеседования ситуация — never как результат исчерпывающей проверки (exhaustiveness check) в switch по union-типу. Если ты обработал все варианты union-а, то в ветке default остаётся тип never — и если позже кто-то добавит новый вариант в union, но забудет обработать его в switch, компилятор сам укажет на ошибку, потому что необработанное значение перестанет быть совместимо с never.

type Shape = "circle" | "square";

function describe(shape: Shape): string {
  switch (shape) {
    case "circle":
      return "Круг";
    case "square":
      return "Квадрат";
    default: {
      const _exhaustive: never = shape; // если добавят "triangle" — ошибка тут
      return _exhaustive;
    }
  }
}

Как это работает под капотом. В теории типов any называют одновременно и top type, и bottom type — он ведёт себя непоследовательно именно потому, что отключает проверки в обе стороны. unknown — честный top type: в него можно поместить что угодно (как в any), но достать из него можно только после проверки (в отличие от any). never — bottom type, полная противоположность: он не содержит вообще ни одного значения, зато совместим с любым другим типом при возврате, потому что раз функция гарантированно не возвращает управление, обещание «я верну строку» никогда не будет нарушено — оно просто никогда не проверяется.

Частые ошибки на собеседовании: путают unknown с any, говоря, что они «одинаковые, просто unknown построже» — на деле это разное поведение, а не разная степень строгости одного и того же. Ещё частая ошибка — писать data as any при первой же ошибке компиляции вместо того, чтобы явно сузить тип: это лечит симптом (ошибку компилятора), но убирает всю пользу от типизации именно там, где она нужна. Наконец, путают never с void: void означает «функция ничего не возвращает (или возвращает undefined)», а функция при этом нормально завершается; never означает, что до конца функции выполнение вообще не доходит.

Проверьте себя
1. Почему unknown считается более безопасной заменой any?
Aunknown нельзя присвоить переменной другого типа
BС unknown нельзя выполнять операции, пока тип не сужен явной проверкой, а с any можно делать что угодно без проверок
Cunknown автоматически преобразуется в строку при выводе
Dunknown запрещает передавать значения в функции
2. Когда TypeScript выводит тип never для переменной в ветке switch?
AКогда переменная равна null
BКогда все варианты union-типа уже обработаны в предыдущих case, и до этой ветки в принципе не может дойти значение
CКогда функция ничего не возвращает
DКогда тип переменной ещё не объявлен