← Глава 10. null, undefined и честность Содержание Глава 12. Объекты как формы →

Глава 11. never, unknown, any

Глава 11. never, unknown, any

Выключатель, к которому тянется рука

Приходит из сети ответ платёжного шлюза. Тип у него — any, потому что JSON.parse иначе не умеет. Надо достать сумму. Рука сама тянется к привычному:

function readTotal(raw: any): number {
  return raw.payment.amount; // компилируется. всё компилируется.
}

Это рефлекс, привезённый из статически типизированного мира: когда тип мешает, возьми что-нибудь максимально широкое и пробейся сквозь. В Java это Object и каст. Здесь — any. И any действительно пробивает: raw.payment.amount проходит проверку, хотя ни payment, ни amount никто не видел. Можно было написать raw.paymnet.amuont, перепутать оба поля и не услышать ни звука. any не широкий тип. Это выключенный проверяющий.

Теперь рядом — другая граница, где приходят чужие данные. Блок catch:

try {
  charge(order);
} catch (e) {
  console.log(e.message); // ошибка: 'e' имеет тип 'unknown'
}

Та же ситуация — значение неизвестного происхождения, — а реакция компилятора противоположная. На any он молчал. Здесь он останавливает: e имеет тип unknown, и трогать e.message нельзя, пока не докажешь, что у e вообще есть message. Два значения «неизвестно чего», две границы, на которых программист каждый день ловит данные из внешнего мира, — и одна из них охраняется, а другая распахнута настежь.

Это и есть кажущееся противоречие, с которым предстоит разобраться. Язык, который в прошлой главе с маниакальной дотошностью гонял нас за каждым возможным null, не давал прочитать поле, пока мы не доказали, что объект существует, — тот же язык раздаёт any, тип, при котором не проверяется вообще ничего. Как одно уживается с другим? И раз есть unknown — честный способ сказать «не знаю, что это», который заставляет проверить, — то зачем вообще нужен any, способ сказать то же самое и не проверять? Чтобы ответить, надо положить рядом три типа, которые завершают алгебру значений, и увидеть, что они не одного сорта.

never — нижний тип, пустое множество

Начнём с границы, которая выглядит бесполезной, пока не поймёшь, зачем она. В главе 6 мы условились видеть в типе множество значений. number — все числа. "paid" — множество из одного элемента. Order — все объекты подходящей формы. Доведём идею до края: а есть ли тип, чьё множество пусто? Тип, в котором нет ни одного значения?

Есть. Он называется never.

const x: never = 1;        // ошибка: 'number' нельзя присвоить 'never'
const y: never = "paid";   // ошибка: 'string' нельзя присвоить 'never'
const z: never = undefined; // ошибка: и это нельзя

В never нельзя положить ничего — потому что в пустом множестве нет элементов по определению. Любое значение, какое ни возьми, не принадлежит пустому множеству, поэтому любое присваивание в never — ошибка. Это первое его свойство, и оно звучит как издевательство: тип, в который нельзя ничего записать. Зачем такой нужен?

Затем, что нулевое множество — подмножество любого множества. Пустое множество содержится в каждом. А подмножество значений — это и есть подтип (глава 6). Значит, never — подтип всего сразу:

function f(): never {
  throw new Error("стоп");
}

const n: number = f();  // ок: never присваивается в number
const s: string = f();  // ок: never присваивается и в string

Присвоить в never нельзя ничего, а сам never присваивается куда угодно. Это не парадокс, а ровно то, что обещает пустое множество: оно — подмножество каждого, поэтому его «значение» (которого не существует) подойдёт под любой тип. never сидит в самом низу решётки типов. Поэтому его и зовут нижним типом (bottom type) — он под всеми.

Где он возникает сам, без нашего участия? Первое место — функции, которые не возвращают управление. Та, что выше, всегда бросает; есть и вечные циклы:

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

function loop(): never {
  while (true) { /* ... */ }
}

Тип возврата здесь не void. void означает «вернётся, но без значения». never означает «не вернётся вовсе» — до точки за вызовом управление не дойдёт. Это честная разница, и компилятор её использует: после fail(...) он считает дальнейший код недостижимым.

Второе место — невозможные пересечения. В главе 7 пересечение (&, intersection) складывало требования: A & B — значение, удовлетворяющее обоим сразу. А если удовлетворить обоим невозможно?

type Impossible = string & number; // тип: never

Не бывает значения, которое одновременно строка и число. Множество таких значений пусто. Компилятор вычисляет пересечение, видит, что общих элементов нет, и честно называет результат: never. Это не ошибка — это правильный ответ. «Сколько значений и строка, и число? Ноль. Имя множеству из нуля значений — never».

Третье — отфильтрованные члены объединения. При сужении (глава 8) компилятор вычитает из union то, что исключено проверками. Вычти всё — останется never:

function check(x: string) {
  if (typeof x === "string") {
    x; // тип: string
  } else {
    x; // тип: never — других вариантов у string не осталось
  }
}

В else-ветке не может оказаться ни одного значения: всё, чем мог быть x, отобрала ветка if. Компилятор обозначает эту пустоту единственным правильным словом. И тут же из этой механики вырастает практический инструмент. Вспомните проверку исчерпанности из главы 9:

type Status = "created" | "paid" | "shipped";

function label(s: Status): string {
  switch (s) {
    case "created": return "создан";
    case "paid":    return "оплачен";
    case "shipped": return "отправлен";
    default:
      const exhaustive: never = s; // s сужен до never — все случаи разобраны
      return exhaustive;
  }
}

Пока разобраны все члены Status, в default приходит never, и присваивание в never проходит. Допишите в Status четвёртый вариант "cancelled" — и s в default сузится уже не до never, а до "cancelled"; присвоить "cancelled" в never нельзя, компилятор укажет на эту строку. Пустое множество стало сторожем: оно загорается ровно тогда, когда вы забыли случай. Так бесполезный на вид тип оказывается одновременно способом компилятора сказать «такого не бывает» и нашим оружием, чтобы заставить его это доказывать.

Последнее свойство, тихое, но важное для следующих глав: never поглощается в объединении.

type T = string | never; // тип: string

T | never всегда сворачивается в T. Добавить к множеству пустое множество — ничего не изменить. В алгебре значений never — это ноль сложения: нейтральный элемент union, как 0 в сумме чисел. Это понадобится, когда типы начнут вычислять другие типы и отфильтрованные варианты будут исчезать сами.

unknown — верхний тип, честное «не знаю»

У множества всех значений есть и противоположный край. Если never — пустое множество внизу, то наверху — множество, содержащее всё. Тип, которому принадлежит любое значение без исключения. Он называется unknown.

let u: unknown;
u = 1;          // ок
u = "paid";     // ок
u = { id: "A" }; // ок
u = null;        // ок

В unknown помещается что угодно — он сверху, он надмножество всех типов. Это верхний тип (top type), зеркало нижнего. И зеркальным образом устроено второе его свойство. В never нельзя ничего положить, но его можно достать и присвоить куда угодно. С unknown наоборот: положить можно что угодно, а вот достать и использовать нельзя ничего, пока не докажешь, что именно лежит внутри.

function size(u: unknown) {
  u.length;        // ошибка: 'u' имеет тип 'unknown'
  u();             // ошибка: 'u' имеет тип 'unknown'
  u.total + 1;     // ошибка: 'u' имеет тип 'unknown'
}

И это правильно. Раз u может быть чем угодно — числом, строкой, null, функцией, заказом, — то ни одно действие над ним не безопасно. У числа нет length. null нельзя вызвать. Компилятор не знает, что внутри, поэтому запрещает всё, что предполагает конкретную форму. Чтобы что-то сделать с unknown, его сперва надо сузить (narrow) — теми же средствами рантайма, что и любой union:

function size(u: unknown): number {
  if (typeof u === "string") {
    return u.length;       // здесь u сужен до string — можно
  }
  if (Array.isArray(u)) {
    return u.length;       // здесь u сужен до массива — можно
  }
  return 0;
}

unknown нельзя трогать наугад, но как только проверка в рантайме доказала форму, компилятор открывает доступ ровно к тому, что доказано. Это честный тип: он принимает любые данные, но требует проверить их перед употреблением. Именно поэтому это правильный тип для всего, что приходит из-за границы типовой системы, где аннотации не действовали.

const raw: unknown = JSON.parse(body);   // не any — unknown

Распарсенный JSON, тело ответа API, прочитанная форма, перехваченное исключение — у всего этого нет проверенного типа, потому что проверяющая программа к этим данным не прикасалась (глава 1). Назвать их unknown — значит сказать правду: «значение есть, форма неизвестна, докажи прежде чем верить». И язык заставит доказать. Этим он и держит ту самую границу, ради которой в прошлой главе так свирепо гонял за null: данные снаружи виновны, пока не проверены.

any — выключатель проверки

Теперь третий, и он не из этого ряда. never и unknown — края алгебры множеств: пустое и полное. Их можно расположить в решётке типов, у них есть подтипы и надтипы, они подчиняются правилам присваивания. any в эту картину не вписывается, потому что он не множество значений вовсе. Это команда проверяющему: «на этом значении не работай».

Видно по тому, как он ведёт себя с присваиванием. У unknown — улица с односторонним движением: всё втекает, ничего не вытекает без доказательства. У any движение в обе стороны:

let a: any;
a = 1;               // ок, как у unknown

const n: number = a; // а вот это unknown бы запретил
const s: string = a; // и это
n.toFixed(2);        // и доступ к любым свойствам разрешён
a.whatever.nested.x; // и это — без единой жалобы

unknown принимал всё, но дальше не пускал. any принимает всё и выпускает во что угодно. Положите any в переменную типа number — компилятор не возразит, хотя там может лежать строка. Это и есть та теоретико-множественная несостоятельность, о которой шла речь ещё в главе 6: any ведёт себя одновременно как надтип всего (в него можно присвоить что угодно) и как подтип всего (его можно присвоить во что угодно). Ни одно настоящее множество так не умеет — оно не может быть и сверху, и снизу решётки сразу. any умеет, потому что он не множество, а отключённая проверка. Там, где значение помечено any, проверяющий просто не смотрит.

Отсюда — его опаснейшее свойство: он расползается. any тих и заразен. Он не остаётся в той переменной, куда вы его пустили, — он течёт по выражениям, и всё, к чему прикоснулся, само становится any:

const data: any = JSON.parse(body);
const order = data.order;      // тип order: any
const total = order.total;     // тип total: any
const sum = total + shipping;  // тип sum: any — зараза дошла досюда
roundMoney(sum);               // и проникла в типизированную функцию

Ни одной ошибки на всём пути. Один any в начале выключил проверку для всей цепочки, и значение sum, на самом деле неизвестно какое, спокойно ушло аргументом в roundMoney, не предъявив никаких документов. Сравните это с unknown, который остановил бы на первом же data.order. В этом вся разница между двумя типами для «неизвестного»:

То самое противоречие из начала главы теперь разнимается на части. catch даёт unknown — граница охраняется. JSON.parse отдаёт any — граница распахнута. Один и тот же по природе данных вход, но один тип заставляет проверить, а другой пропускает не глядя. Знать, который из двух у тебя в руках, — и значит знать, безопасна ли граница.

any — это философия, а не грязь

Остаётся вопрос, на котором держалась завязка главы: зачем язык, способный на честный unknown, вообще завёл any? Если есть тип, который говорит «не знаю» и сторожит, — к чему второй, который говорит то же и не сторожит? Ответ не в системе типов. Он в истории.

TypeScript не проектировали в вакууме под чистый новый код. Его задача с первого дня была другой: натянуть статическую проверку на уже существующий, гигантский, нетипизированный JavaScript — на миллионы строк чужих библиотек, на легаси, которое никто не перепишет, на экосистему npm, написанную без всякой мысли о типах. И натянуть не разом, а постепенно, файл за файлом: вот этот модуль мы типизируем, а тот пока оставим как есть, и оба должны работать в одной сборке.

Это и есть постепенная типизация (gradual typing), и any — её несущий элемент. any — шов, на котором сосуществуют типизированный и нетипизированный код. Когда типизированная функция зовёт нетипизированную библиотеку, на стыке стоит any: типизированная сторона видит хоть что-то определённое, нетипизированная остаётся как была, и сборка не разваливается. Без такого шва пришлось бы либо типизировать всю вселенную JS разом перед первым запуском — невозможно, — либо запретить любой контакт с нетипизированным кодом — бесполезно. any — это компромисс, на котором проект вообще смог прижиться к JavaScript.

У него есть и вторая, постоянная работа — аварийный люк. Случается, что система типов чего-то честно не выражает: вычисление слишком сложно для неё, или библиотека в своём .d.ts описала собственный API неверно и борется с вами, или вы точно знаете про значение то, чего компилятор вывести не может. any — дверь, через которую можно выйти из проверки, когда проверка ошибается или не дотягивается. Альтернатива двери — заваренный наглухо корпус, в котором рано или поздно задохнёшься.

Поэтому any — не грязь и не недосмотр. Это осознанно принятое обязательство, ровно как стирание было осознанным ограничением (глава 1). Цена за то, чтобы язык смог родиться внутри чужой динамической экосистемы, а не рядом с ней. Но обязательство — это долг, и долг надо обслуживать. any оправдан как шов и как люк — узкая, помеченная, временная граница. Он не оправдан как повседневный инструмент, которым глушат любое неудобство компилятора. Уважать причину его существования — не то же самое, что пускать его внутрь. Шов держат запертым; именно поэтому рядом стоит unknown — чтобы у честного «не знаю» был отдельный тип, и any не приходилось звать там, где на самом деле нужна проверка.

Глазами джависта

Рефлекс из начала главы — «возьми что-нибудь пошире и пробейся сквозь» — в Java выглядит как Object и каст. Перенесите его сюда буквально, и он подведёт по обоим краям.

Нижнего типа у Java нет. Нет значения, которое присваивается во всё; нет способа сказать «эта функция не возвращается» в системе типов — метод, который всегда бросает, по типу всё равно void или String, и компилятор не отметит код за throw как недостижимый так, как это делает never. Проверки исчерпанности, которая загорается, когда вы забыли вариант enum, в довесок тоже нет — её приходится изображать руками. never — инструмент, которого в руках джависта не было.

Верхний тип у Java есть — это Object. Но он слабо проверяемый: у Object есть методы (toString, equals, hashCode), их можно звать прямо, не уточняя тип. А чтобы добраться до остального, вы кастуете — ((Order) obj).getTotal() — и каст не проверяется на этапе компиляции: компилятор верит вам на слово, а если вы ошиблись, в рантайме прилетит ClassCastException. Вот ключевая подмена: каст похож на сужение unknown, но это его противоположность. unknown запрещает всё, пока вы не сузите проверкой, и проверку делаете в рантайме честно (typeof, Array.isArray, предикат). Java-каст разрешает обращение без проверки и откладывает расплату до рантайма. То есть Object ближе по духу к any, чем к unknown, — он тоже даёт пробиться сквозь, просто страхует падением вместо тишины.

И главное расхождение — про any. Швы в Java тоже есть: сырые типы (List без параметра), беспроверочные касты, @SuppressWarnings. Но они другой природы — номинативные, проверяемые в рантайме, и это в основном остаток миграции с до-дженерикового кода на дженерики, внутри одного языка. Первоклассного any Java не завела, потому что ей не пришлось вбирать в себя чужую, повсеместно нетипизированную экосистему: Java начиналась типизированной. TypeScript начинался поверх океана нетипизированного JS — и any ему понадобился как несущая конструкция, а не как пятно. Так что рефлекс «бери широкий тип и кастуй» надо разнять надвое: широкий честный тип здесь — unknown, и он требует сузить, а не кастовать; а any — не широкий тип, а шов, и пускать его в дело как замену касту значит выключать проверку там, где Java хотя бы страхует вас исключением.

Где трио кусается

Теперь честно — где эти три типа подводят, режут и портят кровь.

Сторожа never можно ослепить утверждением. Проверка исчерпанности из главы 9 держалась на том, что в default приходит never. Сам по себе any сюда не пролезет — он не присваивается в never (это единственный тип, которому any не подчиняется), и const exhaustive: never = s as any честно даст ошибку. Но рядом стоит as — утверждение типа, которое переписывает убеждение компилятора без проверки, и им сторожа гасят напрямую:

type Status = "created" | "paid" | "shipped";

function label(s: Status): string {
  switch (s) {
    case "created": return "создан";
    case "paid":    return "оплачен";
    // "shipped" забыли
    default:
      const exhaustive: never = s as never; // as never — и сторож ослеп
      return exhaustive;
  }
}

Один as never — и s, на деле сужённый до "shipped", объявлен пустым множеством, хотя значение в нём есть. Сторож, которого мы так старательно ставили, обезврежен: добавите вы забытый "shipped" или нет, компилятор промолчит. Конструкцию, которой мы ловили забытые случаи, сносит та же дыра, что и любую другую гарантию, — утверждение as, против которого never бессилен.

Трение unknown гонит обратно к any. unknown честен, но неудобен: каждый доступ требует сужения, и под усталость рука сама тянется заменить unknown на any, чтобы «просто заработало».

const raw: unknown = JSON.parse(body);
// надо проверить payment, amount, тип каждого поля...
const total = (raw as any).payment.amount; // сдались

Один as any отменяет всю защиту, которую unknown выстраивал. Самое слабое место — не сам тип, а соблазн от него отказаться. Честность стоит усилий, и язык не мешает вам перестать быть честным.

Случайный never трудно диагностировать. Иногда never появляется там, где вы его не звали, и сообщения об ошибке сбивают с толку. Классика — пустой массив в позиции, где компилятор не может его «достроить». В простой локальной переменной (const ids = []) TS прощает пустой литерал и доращивает его тип по первому push. А вот пустой литерал внутри объекта так уже не эволюционирует:

const state = { ids: [] };  // тип ids: never[] — массив, в который ничего не положить
state.ids.push("A-1");      // ошибка: '"A-1"' нельзя присвоить параметру 'never'

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

as отмывает что угодно. Утверждение типа (глава 1) поверх unknown или any переписывает убеждение компилятора без всякой проверки:

const order = JSON.parse(body) as Order; // ни одно поле не проверено
order.total.toFixed(2);                  // рантайм решит, был ли там total

unknown заставлял доказывать форму — но as Order объявляет форму доказанной, ничего не проверив. Так из честного «не знаю» одним словом делается ложное «знаю точно», и граница, которую unknown сторожил, снова распахнута.

Чужие any впрыскивают дыры. Библиотека, чьи типы возвращают any, заражает ваш строгий код, даже если у вас strict и ни одного any в проекте:

import { parse } from "some-lib"; // parse(...): any по своим .d.ts

const order = parse(body); // тип: any, и пошло протекать дальше
order.total.foo.bar;       // тишина — чужой any выключил вашу проверку

Вы не писали any, но он пришёл с зависимостью и потёк по вашему коду по своим правилам. Строгость вашего проекта кончается там, где начинается чужой any, — а узнать об этом без внимания к типам возвращаемых значений нельзя. Это прямая цена постепенной типизации: шов держит вашу сторону, но не чужую.

never = «не бывает», unknown = «пока не знаю», any = «проверка выключена»

Переключатель этой главы — научиться выбирать из трёх тот, что соответствует правде о значении. Они не взаимозаменяемы и не лежат по шкале «строже — слабее». Каждый утверждает своё, и задача — называть вещь её именем.

never — это «такого не бывает». Произносите его, когда хотите, чтобы компилятор подтвердил невозможность: что все случаи разобраны, что функция не вернётся, что ветка недостижима. Это свидетель исчерпанности (глава 9) и, дальше, инструмент моделирования, когда нужен тип с нулём обитателей, чтобы недопустимое состояние нельзя было даже выразить (глава 29). never — не аварийная ситуация, а утверждение, которое вы доказываете компилятору.

unknown — это «пока не знаю». Это правильное значение по умолчанию для всего, что приходит из-за границы: JSON, ввод, ответ API, перехваченное исключение. Произнесите unknown — и язык заставит вас заплатить за знание формы единственно честной монетой: проверкой в рантайме (главы 25, 26). Это не неудобство, которое надо обойти, а ровно та дисциплина на стыках, ради которой вся вторая часть книги и затевалась. Снаружи всё виновно, пока не проверено, и unknown — тип, который это правило кодирует.

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

Сведите всё в один рефлекс на каждой границе: данные снаружи — unknown, и плати доказательством; невозможность — never, и докажи её компилятору; выключенная проверка — any, и запри её на месте.

Итог

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

← Глава 10. null, undefined и честность Содержание Глава 12. Объекты как формы →