← Глава 5. Компилятор как доказательство Содержание Глава 7. Суммы и произведения →

Глава 6. Множества, а не классы

Глава 6. Множества, а не классы

Тип, который перечисляет значения

Вот объявление, на котором интуиция, привезённая из статически типизированного ООП, спотыкается в первый раз по-настоящему:

type Method = "GET" | "POST" | "PUT";

Прочитайте, что здесь написано, буквально. Тип Method задан через три строковых литерала, соединённых вертикальной чертой. Не через имя класса, не через список членов перечисления, не через ссылку на родителя. Через сами значения. "GET" тут — не строка-значение внутри типа string, а часть определения типа.

Дальше — хуже. Возьмём одну из этих строк отдельно:

type Get = "GET"; // это тип

"GET" сам по себе — полноправный тип. Не значение, которое куда-то кладут, а тип, в который что-то кладут:

const m: "GET" = "GET"; // годится
const n: "GET" = "POST";
// Error: Type '"POST"' is not assignable to type '"GET"'.

И с другой стороны:

type Id = string | number; // тоже тип

string | number — тип, собранный из двух других типов сложением. Тоже без единого имени класса, без иерархии, без объявленного родства между string и number.

Привычная модель типа здесь не помогает, а мешает. В ней тип — это коробка в иерархии: узел с именем, у которого есть предки и потомки, и значение принадлежит типу, если оно создано как экземпляр этого класса или его наследника. В такой модели нет ячейки ни для "GET", ни для string | number. У "GET" нет конструктора и нет родителя. string | number не наследует ни от чего и ни от чего не наследуется. Спросить «какой класс реализует тип Method» бессмысленно — никакой.

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

Тип — это множество значений

Ответ умещается в одну фразу, и на ней стоит вся часть II книги: тип — это множество значений, которые ему принадлежат, и ничего сверх этого.

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

type Bit = boolean;

Какие значения имеют тип boolean? Ровно два: true и false. Значит, boolean — это множество из двух элементов: {true, false}. Не больше и не меньше. Это видно прямо в языке — тип boolean можно записать как сумму двух его значений, и это будет тот же самый тип:

type Bit = true | false; // в точности boolean

const x: Bit = true;  // годится
const y: Bit = false; // годится
// третьего значения нет — множество исчерпано

Теперь одноэлементное множество. Литеральный тип (literal type) — тип, чьё множество состоит из единственного значения:

type Get = "GET"; // множество из одного элемента: {"GET"}

В "GET" входит ровно одно значение — строка "GET". Любое другое значение в это множество не попадает, и поэтому присвоить его нельзя. Отсюда и ошибка из начала главы: "POST" не входит в множество {"GET"}.

Дальше — множества побольше. string — это множество всех строк, какие вообще бывают: "", "GET", "привет", любая. Бесконечное множество. number — множество всех чисел. Тоже бесконечное. И литерал, и string — типы, разница между ними только в размере множества: у "GET" один элемент, у string — все строки сразу.

Вот тут модель начинает окупаться. Связь между "GET" и string не нужно объявлять и неоткуда выводить через наследование. Она уже есть, и она арифметическая: множество {"GET"} целиком лежит внутри множества всех строк. "GET" — это string, который сузили до одного значения. Между ними не отношение «потомок — предок», а отношение «подмножество — надмножество». Это и есть ключ ко всей главе, и следующий шаг разворачивает его в правило.

Присваиваемость — это вложенность множеств

Когда компилятор решает, можно ли значение типа A положить туда, где ждут тип B, он отвечает на один вопрос — теоретико-множественный: является ли множество A подмножеством множества B? Если каждое значение из A заведомо лежит и в B, присваивание безопасно: что бы ни пришло из A, оно окажется законным значением B. Если же в A есть хоть одно значение, не попадающее в B, присваивать нельзя — оно нарушит обещание.

let s: string;
const g: "GET" = "GET";
s = g; // годится: {"GET"} ⊆ все строки

g имеет тип "GET" — множество {"GET"}. Оно целиком внутри множества всех строк, поэтому положить g в string-переменную безопасно. Обратно — нельзя:

let g2: "GET";
const s2: string = "GET";
g2 = s2;
// Error: Type 'string' is not assignable to type '"GET"'.

Хотя в s2 лежит именно "GET", тип s2 — это string, всё множество строк. В нём полно значений, не входящих в {"GET"}, и компилятор рассуждает по типу, а не по тому, что случайно лежит в переменной сейчас. Множество string не вкладывается в множество {"GET"} — присваивание отклонено.

«Присваиваемо» (assignable) — это просто «подмножество». Подтип — это подмножество. Меньше значений в типе — он «уже», он подтип; больше значений — он «шире», он надтип. Никакой иерархии классов для этого не требуется: вложенность множеств существует сама по себе, её не надо декларировать.

А теперь — место, где интуиция из мира классов разворачивается на сто восемьдесят градусов. Перенесём правило на объекты.

type Order = { id: string; total: number };
type WithStatus = { id: string; total: number; status: string };

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

Множество Order — это все объекты, у которых есть id: string и total: number (и неважно, что ещё). Множество WithStatus — все объекты, у которых есть id, total и status: string. Третье требование — это дополнительный фильтр. Каждый объект, проходящий фильтр WithStatus, заведомо проходит и фильтр Order (у него есть id и total). Но не наоборот: объект { id: "A-1", total: 500 } входит в Order и не входит в WithStatus. Значит, WithStatus ⊆ Order: множество с лишним свойством — меньше, потому что каждое требование выбрасывает кандидатов.

const big: WithStatus = { id: "A-1", total: 500, status: "paid" };
const ord: Order = big; // годится: WithStatus ⊆ Order

const small: Order = { id: "A-2", total: 300 };
const ws: WithStatus = small;
// Error: Property 'status' is missing in type 'Order'
//        but required in type 'WithStatus'.

Больше полей в типе объекта — уже множество — подтип. Это та же вложенность множеств, что и со строками, просто направление сбивает с толку всех, кто привык думать «больше = специализированнее = ниже в иерархии». Здесь иерархии нет вообще. Есть множества значений и вопрос, какое в каком лежит, — и ответ считается, а не объявляется.

Объединение — это объединение множеств

Раз тип — множество, над типами должны работать операции над множествами. Первая из них уже мелькала: вертикальная черта.

type Method = "GET" | "POST" | "PUT";

A | B — это union, объединение: тип, чьё множество есть объединение множеств A и B. Значение принадлежит A | B, если оно лежит хотя бы в одном из них. Method — это {"GET"} ∪ {"POST"} ∪ {"PUT"}, то есть множество из трёх строк:

const a: Method = "GET";  // в множестве
const b: Method = "POST"; // в множестве
const c: Method = "HEAD";
// Error: Type '"HEAD"' is not assignable to type 'Method'.
// "HEAD" не лежит ни в одном из трёх слагаемых

То же с разнородными типами:

type Id = string | number; // все строки ∪ все числа

const i1: Id = "A-1"; // лежит в множестве строк
const i2: Id = 42;    // лежит в множестве чисел
const i3: Id = true;
// Error: Type 'boolean' is not assignable to type 'Id'.

Объединение немедленно состыковывается с присваиваемостью предыдущей секции, и стыкуется без новых правил. Каждое слагаемое union’а — подмножество целого, поэтому каждое присваиваемо в union:

const get: "GET" = "GET";
const m: Method = get; // годится: {"GET"} ⊆ Method

А вот доставать значение из union’а в одно из слагаемых нельзя без проверки — по той же арифметике. Само целое не вкладывается в часть:

function first(method: Method) {
  const justGet: "GET" = method;
  // Error: Type 'Method' is not assignable to type '"GET"'.
  //   Type '"POST"' is not assignable to type '"GET"'.
  // Method шире, чем {"GET"}: вдруг там "POST"
}

Чтобы взять из Method именно "GET", нужно сузить множество проверкой в коде — это narrowing, сужение, которому посвящена глава 8. Сейчас довольно увидеть, что union перестал быть отдельной фичей, которую надо заучивать. Это просто сложение множеств, и всё его поведение — присваиваемость частей в целое, недоступность целого как части — вытекает из того, что вы уже знаете про вложенность. Полную алгебру сумм и произведений разворачивает глава 7; здесь важно, что само объединение стало самоочевидным ровно в тот момент, когда вы перестали видеть коробки и увидели множества.

Пустое, полное и жулик: never, unknown, any

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

never — пустое множество. Тип, которому не принадлежит ни одно значение. В never нельзя ничего положить, потому что класть нечего: ни одно значение не входит в пустое множество.

const x: never = "что угодно";
// Error: Type 'string' is not assignable to type 'never'.
// в пустое множество не входит ничего

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

function fail(msg: string): never {
  throw new Error(msg); // не возвращается: бросает
}

И главное практическое следствие — проверка исчерпанности (exhaustiveness check), на которой держатся размеченные объединения главы 9. Если вы разобрали все варианты union’а, то в недостижимой ветке у переменной остаётся пустое множество — never, — и компилятор это знает:

function handle(m: Method): string {
  switch (m) {
    case "GET":  return "чтение";
    case "POST": return "создание";
    case "PUT":  return "замена";
    default:
      // сюда не попасть: все три варианта разобраны,
      // остаток множества Method пуст
      const rest: never = m; // годится — пока union исчерпан
      return rest;
  }
}

Допишите в Method четвёртый метод — и остаток в default перестанет быть пустым, в нём окажется "DELETE", присвоить его в never будет нельзя, и компилятор укажет на забытую ветку. Пустое множество работает сторожем полноты.

unknown — универсальное множество. Тип, которому принадлежит любое значение. В unknown можно положить что угодно — каждое значение входит в полное множество:

let u: unknown;
u = "A-1";       // годится
u = 42;          // годится
u = { id: "x" }; // годится — всё входит

И, симметрично пустому, — обратное направление закрыто. Раз в unknown лежит всё, про конкретную переменную нельзя утверждать ничего, пока вы не сузили её множество до меньшего:

const len = u.length;
// Error: 'u' is of type 'unknown'.
// в полном множестве есть значения без .length — нельзя

if (typeof u === "string") {
  const len2 = u.length; // годится: сузили unknown до string
}

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

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

let a: any;
a = "A-1"; // годится — как unknown
const n: number = a; // тоже годится — а вот так unknown не умеет
n.toFixed(2);        // проверка молчит. рантайм может упасть

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

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

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

В Java тип — это узел номинативной иерархии. У него есть имя, объявленные предки и потомки, и значение принадлежит типу, если оно создано как экземпляр этого класса или его наследника. Подтип — это объявленное родство: class Premium extends Order делает Premium подтипом Order потому, что так написано в extends, а не потому, что кто-то сравнил множества значений. Связь существует, только если её провозгласили.

Поэтому в Java нет ячейки для того, что в этой главе было очевидным. Нет литеральных одноэлементных типов: "GET" — это значение типа String, и сделать из него тип нельзя. Нет String | Integer: у этих классов нет общего объявленного предка, кроме Object, и сложить их в один тип языку нечем — Object тут не сумма, а «забыли, что это было». Подтип здесь не считается через вложенность множеств, а ищется вверх по extends/implements.

enum кажется близким к Method, но это другое животное. Java-enum — закрытый номинативный класс с фиксированным набором экземпляров; это снова узел иерархии, у которого просто пересчитаны инстансы, а не операция над значениями. Сложить два enum’а в один тип нельзя, вынуть один константный вариант как самостоятельный тип нельзя. sealed-классы и sealed interface подходят ближе всего: они ограничивают множество подтипов сверху, и компилятор по ним умеет проверять исчерпанность switch. Но и это — перечень разрешённых узлов иерархии, объявленный заранее в одном месте, а не свободная арифметика над множествами значений, собираемая по месту из чего угодно, включая литералы и примитивы.

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

Где метафора множеств протекает

Модель множеств — не украшение, а рабочая механика: компилятор действительно рассуждает о присваиваемости как о вложенности. Но честность требует показать, где у этой механики края.

Пересечение выворачивает интуицию ещё раз. К union есть парная операция — intersection, пересечение, записываемое &. По имени ждёшь, что A & B — это общая часть, что-то меньшее. Для объектов выходит наоборот:

type A = { id: string };
type B = { total: number };
type AB = A & B; // { id: string; total: number }

const v: AB = { id: "A-1", total: 500 };
// нужны оба поля — это пересечение требований,
// но множество таких объектов МЕНЬШЕ каждого из A и B

Разгадка в том, что множество объектов задаётся не перечислением, а набором требований, и пересечение пересекает именно требования. Объект принадлежит A & B, только если удовлетворяет требованиям и A, и B одновременно, — а каждое лишнее требование отсекает кандидатов. Поэтому пересечение требований даёт пересечение множеств значений, которое и правда меньше: AB ⊆ A и AB ⊆ B. Метафора не врёт, но требует считать множества значений, а не складывать поля на глаз. Полную алгебру & и | развернёт глава 7; здесь — предупреждение, что направление «больше формы — меньше множество» в TypeScript устойчиво и контринтуитивно.

Расширение литералов меняет множество за вашей спиной. Множество, которое вы фактически получаете, не всегда то, что написано, — из-за того, как выводятся типы (глава 4):

const a = "GET";        // тип "GET" — множество из одного элемента
let   b = "GET";        // тип string — всё множество строк!

const obj = { m: "GET" };
// тип obj.m — string, а не "GET"

const a фиксирует литеральное множество {"GET"}, потому что const-переменную не переприсвоить. А let b компилятор расширяет (widening) до string: переменную можно переприсвоить, и язык предполагает, что вы захотите положить туда другую строку. То же с полем объекта. В результате множество, с которым вы работаете, оказывается шире написанного литерала, и это регулярно ломает ожидания при работе с union’ами литералов. Лечится явной аннотацией или as const, фиксирующим литеральные множества; механика — в главах 4 и 8.

Не всякое множество выразимо. Самая важная честность. Модель говорит «тип — это множество значений», но из этого не следует, что в TypeScript можно записать любое множество. Доступна конкретная алгебра — объединение, пересечение, литералы, несколько операций над формами, — а не вся теория множеств.

// чего сказать нельзя:
// «только чётные числа»
// «строка длиной ровно 4 символа»
// «положительное число»
type Even = number; // приходится брать всё множество чисел

«Чётные числа» — корректное подмножество number, но его нечем выделить средствами системы типов: предиката «делится на два» на уровне типов нет. То же с «непустой строкой» или «положительным числом». Эти множества существуют математически и не существуют как типы. Часть таких ограничений достраивают branded-типами — приёмом, который синтетически метит значение, прошедшее рантайм-проверку, и тем выкраивает нужное подмножество вручную (глава 29). Но это надстройка, а не свойство языка: по умолчанию вам доступна арифметика множеств, а не их произвольное задание.

Края метафоры не отменяют её. Они уточняют: это арифметика множеств — мощная, считаемая компилятором, контринтуитивная по направлению и неполная по выразительности. Ровно с такими оговорками ею и надо пользоваться.

Думай множествами — и часть II напишет себя

Сдвиг этой главы — не новый синтаксис, а замена картинки в голове. Перестаньте видеть в типе коробку, узел, класс с предками и потомками. Начните видеть множество значений — набор того, что в тип входит, и больше ничего.

Как только эта картинка встаёт на место, вся часть II перестаёт быть списком отдельных приёмов и оказывается арифметикой над множествами, где каждая операция имеет ровно один смысл:

Дальше всё выводится из этого, а не запоминается поверх этого. Размеченные объединения (глава 9) — это union, у которого по одному полю можно вычислить, в каком слагаемом ты сейчас. Утилитные типы (глава 23) — это операции, преобразующие одни множества в другие. Проектирование «сделать недопустимое непредставимым» (глава 29) — это искусство выкроить тип, в чьём множестве нет ни одного неверного значения. Когда основная картинка — множество, эти главы не приносят новых правил. Они применяют одно и то же правило к новым формам.

Итог

Тип — это множество значений, которые ему принадлежат, и ничего сверх этого. Кто держит эту модель в голове, для того присваивание становится вложенностью, объединение и пересечение — арифметикой, а never и unknown — пустым и полным множеством; и вся часть II читается не как набор синтаксиса, а как счёт.

← Глава 5. Компилятор как доказательство Содержание Глава 7. Суммы и произведения →