Глава 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. В этом вся разница между двумя типами для «неизвестного»:
unknown— проверка включена и требует доказательства. Принимает всё, не даёт сделать ничего без сужения. Незнание, которое честно сторожит границу.any— проверка выключена. Принимает всё, разрешает всё, протекает дальше. Не незнание, а отказ знать.
То самое противоречие из начала главы теперь разнимается на части. 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. Но они другой природы — номинативные, проверяемые в рантайме, и это в основном остаток миграции с до-дженерикового кода на дженерики, внутри одного языка. ПервоклассногоanyJava не завела, потому что ей не пришлось вбирать в себя чужую, повсеместно нетипизированную экосистему: 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, и запри её на месте.
Итог
На этом алгебра значений собрана: множества вместо классов, суммы и произведения, литералы и сужение, объединения, честное отсутствие — и три предельных типа, замыкающих картину снизу, сверху и сбоку. Кто различает, где «не бывает», где «пока не знаю» и где «проверка выключена», тот держит границы этой алгебры под контролем — а значит, держит под контролем и ту границу рантайма, на которой всё решается.