Это перевод, но перевод моей собственной статьи, так что не спешите убегать на «неповторимый оригинал».

Я пишу на тайпскрипте уже довольно давно. Но некоторые вопросы все еще сбивают меня с толку:

  • Если мне нужен объект, который реализует и { name: string }, и { age: number }, нужно эти типы & (пересечь) или | (объединить)? В каждом варианте можно найти логику, потому что я хочу левое и правое, но, с другой стороны, мне нужно объединение интерфейсов.

  • Сработает ли type S<T> = T extends string ? ..., если T — юнион строк, вроде 'ru' | 'de'?

  • В чем разница межу any и unknown? Лучшее предложение интернета — дурацкие мнемоники типа «Avoid Any, Use Unknown». А что не так с any?

  • never — что за тип? «never по-английски значит НИКОГДА, и это значение НИКОГДА не появится в программе» звучит очень драматично, но не сильно помогает.

  • Если never это какой-то взрыв, то почему я могу const x: number = y as never? И почему never всегда extends X?

  • const x: {} = true; — правильно типизированный код. Ну как это вообще, а? true точно не пустой объект.

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

Если впустить в свою душу идею, что тип — это просто множество значений, всё встает на свои места. Я ушел в пещеру, разобрал все свои знания о тайпскрипте, а потом собрал их на место по чертежу из теории множеств, и получилось логично. Давайте сделаем это вместе:

  • Освежим наши знания о теории множеств.

  • Посмотрим, как понятия TS соотносятся с множествами и операциями на них.

  • Для разминки переведем на язык множеств булевы типы (а заодно — null и undefined).

  • Обобщим это на числа (и походу выясним, какие типы TS вообще не может выразить).

  • Перейдем к интерфейсам — оказывается, они работают совсем не так, как я думал!

  • И на десерт — разложим по полочкам any и undefined.

В конце я нахожу ответы на все свои вопросы, выстраиваю TS в стройную теорию, и рисую эту великолепную диаграмму:

Теория множеств

Но для начала освежим в памяти теорию множеств. Если вы и так все знаете, листайте дальше, но я кончал университеты давно и хотя бы для себя распишу, что к чему.

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

  1. Пакет с яблоком васей, { вася } — множества пишут как элементы в фигурных скобках.

  2. Пакет с яблоком петей, { петя }, ничего нового.

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

  4. Еще можно вообще ничего не класть в пакет, получится пустое множество. Для него есть особый символ ∅

Множества часто изображают на диаграммах Венна — как будто все элементы разложены на плоскости, и мы обводим их кружочками:

Вместо того, чтобы перечислять все элементы, множество можно определить условием. Например, «R — множество красных яблок» это R = { вася } (если вася — красный, а петя ещё зелёный).

Множество A называют подмножеством B, если все элементы A входят в B. В нашем яблочном мире { вася } - подмножество { вася, петя }, но { петя } — не подмножество { вася }. Обратите внимание:

  • Любое множество — подмножество самого себя.

  • Любое множество — подмножество универсального множества.

  • Пустое множество — подмножество любого множества.

Несколько полезных операций с множествами:

  • Объединение C = A ∪ B — все элементы, которые входят хотя бы в A или в B (свалили два пакета в один). Конечно же, A ∪ ∅ = A

  • Пересечение C = A ∩ B — все элементы из A, которые входят еще и в B. Логично, что A ∩ ∅ = ∅

  • Разность C = A \ B — все элементы из A, которых нет в B. Без сомнений, A \ ∅ = A

Всё, этого должно хватить, чтобы разобраться в тайпскрипте. Посмотрим, как применить эти понятия к типам.

Казалось бы, при чем тут типы?

Итак, невероятный поворот: в принципе, тип — множество JavaScript значений. Подробнее:

  1. Универсальное множество — вообще все значения, которые могут появиться в JS-программе.

  2. Тип (даже не TS-тип, просто тип) — какое-то множество JS-значений.

  3. TS может описать некоторые типы, а некоторые — не может. Не верите? Попробуйте написать тип «все числа, кроме 0».

  4. A extends B из условных типов и констрейнтов можно читать как «A — подмножество B».

  5. TS-операторы | и & — как раз объединение и пересечение типов как множеств.

  6. Exclude<A, B> по идее моделирует разность множеств, но этот джинерик работает не для всех A и B (вспоминаем пример с числом-кроме-0, Exclude<number, 0> не работает).

  7. never — пустое множество. Доказательство: для любого A A & never = never b A | never = A, а Exclude<0, 0> = never.

Понимаю, что сложно сразу это принять, так что попробуем на примере.

Булевы типы

Сделаем вид, что в JS есть только булевы значения (я не хотел бы писать на этом). Таких значений ровно два: true и false, или, как говорил наш препод, трюэ и фалзё. Это те же яблочки, только в профиль. На булевых значениях можно составить 4 типа:

  • Типы-литералы true и false, в каждом — по одному значению.

  • boolean, тип из обоих булевых значений.

  • И never в роли пустого множества.

Диаграмма получится та же, что и для яблок:

Поупражняемся в телепортации из мира множеств в мир типов:

  • boolean — то же, что true | false (на удивление, именно так этот тип и реализован в TS)

  • true — подмножество (или подтип) boolean

  • never — пустое множество, значит, never — подмножество типов true, false и boolean

  • & — пересечение, значит, false & true = never, boolean & true = { true, false } | { true } = true (то есть универсальный boolean не влияет на пересечение), true &amp; never = never и так далее.

  • | — объединение, значит, true | never = true, а boolean | true = boolean (то есть универсальный boolean «проглатывает» все остальные элементы объединения, потому что они уже являются его подмножествами).

  • И даже Exclude правильно вычисляет разность множеств: Exclude<boolean, true> = false (в общем случае для других типов это не так).

Теперь потренируемся на extends-условиях:

type A = boolean extends never ? 1 : 0;
type B = true extends boolean ? 1 : 0;
type C = never extends false ? 1 : 0;
type D = never extends never ? 1 : 0;

Если вспомнить, что extends можно читать как «является подмножеством», ответить легко — A0,B1,C1,D1. Хотя интуитивно сложно понять, как never может что-то экстендить. Это успех.

Типы null и undefined устроены так же, как и boolean, но в каждом из них всего по одному значению (или по два TS-типа с учетом never). null & boolean = null & undefined = boolean & undefined = never, потому что одно значение никак не может быть сразу двух JS-типов (то есть базовые JS-типы — непересекающиеся множества). Нанесем всё это на нашу карту:

Строки и другие примитивы

Окей, с простыми типами разобрались, перейдем к строкам. На первый взгляд кажется, что тут всё то же самое: string — тип всех JS-строк, а у каждой конкретной строки есть свой литерал-тип: const str: 'hi' = 'hi'. Но есть один маленький нюанс — строк, в отличие от булевых значений, бесконечно много. (В память компьютера влезет только конечное количество строк? Не душните, их достаточно, чтобы перечислять все было непрактично. К тому же, системе типов негоже ограничивать себя грязным реальным миром).

Как и множества вообще, строковые типы в TS можно определять несколькими способами:

  • Через объединение | можно задать любое конечное множество (тип) строк — например, type Country = 'de' | 'us'. А вот бесконечное (например, все строки длиннее двух символов) — нельзя, потому что написать бесконечный список элементов довольно проблематично.

  • (Относительно) свежая фича TS — шаблонные строковые типы — умеет определять некоторые бесконечные множества — например, type V = `v${string}` — множество всех строк, которые начинаются с v

Мы сможем наковырять ещё несколько типов, объединяя и пересекая шаблоны и литералы. TS достаточно крут, чтобы смержить шаблон и объединение литералов: 'a' | 'b' & `a${string}` = 'a'. Ещё TS старается смержить пересечение шаблонов, но получается не всегда: `a${string}` & `b${string}` — очень извращённая запись never, потому что строка не может одновременно начинаться и с a, и с b.

Но как бы мы ни старались, некоторые строковые типы описать в TS не выйдет. Из простого — попробуйте придумать тип для любой строки, кроме 'a'. На ум приходит Exclude<string, 'a'>, но, посколько TS не моделирует тип string как объединение всех возможных литералов, это не сработает и в результате мы получим снова string. Шаблоны тоже не могут выразить этот тип.

Типы чисел, символов и бигинтов работают так же, но там даже нет шаблонов, так что мы ограничены конечными множествами. А мне бы пригодились типы «целое число», «число от 0 до 1» или «положительное число». Ну да ладно, всё вместе:

Уф, примитивы обсудили! Надеюсь, мы научились переходить с языка типов на язык множеств и обратно. Заодно мы убедились, что вовсе не все типы можно записать на TS. Теперь — самое сложное.

Интерфейсы и типы объектов

Если вы совершенно уверены, что const x: {} = 9 — баг TS, сейчас мы вместе убедимся что это не так. Оказывается, в этом есть логика, просто наше представление о TS-объектах (они же интерфейсы, они же Record) построено на неправильных предпосылках.

Во первых, по аналогии с примитивными типами логично предположить, что type Sum9 = { sum: 9 } — тип для объекта-литерала, в который влезет только объект { sum: 9 }. Так вот, это работает совсем наоборот. Тип Sum9 стоит читать как «штучка, у которой по ключу sum можно достать число 9». То есть каждый тип поля в интерфейсе — условие, которое отсекает что-то от множества «штук». И обычно такой подход довольно полезен — ведь все любят пихать в функцию (data: Sum9) => number объекты с дополнительными свойствами вроде obj = { sum: 9, date: '2022-09-13' } без ругани от TS.

Значит, и type O = {} — не тип «пустой объект» для литерала {}, а «штучка, у которой можно получать доступ к свойствам, но в целом свойства мне не нужны». Становится понятнее, как работает наш «баг»: если x = 9, то штучка x удолетворяет нашему описанию в интерфейсе {}. Спасибо автобоксингу, можно делать даже более смелые утверждения вроде const x: { toString(): string } = 9 — мы же можем вызвать x.toString() и получить строку? Можем. Все честно. А вот null и undefined в наш интерфейс не влезут, потому что у них принципиально нельзя получить никакое свойство. Не могу сказать, что это супер-интуитивно, но теперь по крайней мере логично.

Если помните, я путаю | и &. Так вот, эти операторы действуют на типы как на множества объектов, а не на «форму объектов» или «множества свойств». Если мне нужны объекты, у которых есть и name, и age, то нужно использовать объединение — { name: string } & { age: number }.

А что насчет типа object? Поскольку каждое свойство в интерфейсе отрезает какую-то часть значений от множества почти-всех-значений, у нас не выйдет аккуратно убрать все примитивы. И поэтому в TS есть специальный базовый тип, который как раз и обозначает «JS-объект, а не примитив». Конечно, интерфейс можно пересекать с типом object, чтобы получить «JS-объекты с нужными свойствами, но не примитивы» — например, object &amp; { toString(): string } не содержит число 9.

Добавим эти типы на нашу схему (почти закончили):

Пара слов про extends

Для последнего рывка нужно хорошенько разобраться с extends. Это слово из ООП, где тип расширяет своего родителя в смысле добавления новой функциональности, а с точки зрения множеств оно скорее путает нас — ведь расширенное множество в геометрическом смысле должно быть больше исходного.

Я предлагаю не зацикливаться на этом и не представлять цепочки наследования, которых тут нет. Просто читайте A extends B как «A является подмножеством B». На примерах:

  • 0 | 1 extends 0 — ложь, потому что {0, 1} — не подмножество {0} (даже хотя {0,1} расширяет {1} в геометрическом смысле).

  • never extends T всегда правда, потому что пустое множество never — подмножество любого другого множества. Какого-то здравого смысла тут нет, просто так работает модель.

  • T extends never выполняется только для T = never, потому что у пустого множества нет подмножеств кроме себя.

  • В T extends string без проблем влезут и литерал, и шаблон, и любое их объединение, и сам string, потому что все они — подмножества string.

  • А вот T extends string ? string extends T ? проверяет, что T точно совпадает с типом string, потому что только string является одновременно и подмножеством, и надмножеством string.

unknown и any

В TS не один, а целых два типа, которые моделируют произвольное JS-значение: unknown and any. В чём разница? unknown хорошо ложится в наше объяснение с множествами. Это универсальное множество всех JS-значений, без каких-то конкретных обещаний. Тут есть и null, и undefined, и любой объект, и число:

// Тут будет 1
type Y = string | number | boolean | object | bigint | symbol | null | undefined extends unknown ? 1 : 0;
// Покороче, с учетом странностей {}
type Y2 = {} | null | undefined extends unknown ? 1 : 0;
// Для всех остальных типов тут будет 0:
type N = unknown extends string ? 1 : 0;

Хотя есть и странность. unknown не реализован как объединение всех базовых типов, так что сделать Exclude<unknown, string> не выйдет. unknown extends string | number | boolean | object | bigint | symbol | null | undefined не выполняется, из чего теоретически следует что в JS бывают ещё какие-то другие значения (это не так). Ну, что делать, деталь реализации.

А вот any с точки зрения типов-множеств ведет себя странно: any extends string ? 1 : 0 возвращает 0 | 1, то есть «не знаю». И даже any extends never ? 1 : 0 возвращает 0 | 1, то есть any может быть и пустым множеством.

Из этого можно было бы заключить, что any — «какое-то множество, но мы не знаем, какое» — вроде NaN в мире типов. Но эта гипотеза ломается о то, что на вопросы string extends any, unknown extends any и даже any extends any TS уверенно отвечает «да» вместо «не знаю». Так что any — парадокс множеств, и анализировать его с этой точки зрения бессмысленно.Единственная хорошая новость — any extends unknown, так что в any не входит никаких чудо-значений, и unknown — все еще все JS-значения.

Закончим нашу великолепную карту типов, завернув её в unknown, и добавим any в роли перста Божьего:

Сегодня мы узнали, что типы TS — просто множества JS-значений. Вот небольшой множество-типовой разговорник:

  • unknown — универсальное множество (все JS-значения)

  • never — пустое множество

  • A extends B — А является подмножеством B

  • | — объединение множеств, & — пересечение

  • Exclude — непереводимый фольклор, примерно соответствующий разности множеств.

С этими новыми знаниями вернемся к моим вопросам:

  • & и | работают только на множествах значений, а не на «форме объектов». Если я хочу объект, который удовлетворяет сразу двум интерфейсам, их надо пересечь.

  • type <T> = T extends string ? ... сработает и для T = 'string', и для T = 'a' | 'b', потому что все эти типы — подмножества string

  • unknown — хорошая модель «множества всех JS-значений». any просто отключает проверку типов и ведет себя нелогично.

  • never — не НИКОГДА, а пустое множество. Раз extends читается как «является подмножеством», то вполне логично и то, что объединение с ним не меняет исходное множество, и что never extends что угодно (ведь все 0 элементов пустого множества содержатся в чём угодно)

  • {} — не особый тип, в который влезает только пустой объект, а «штука без ограничений по типам свойств», так что число 9 вполне подходит.

На сегодня всё! Если вам было интересно, подписывайтесь на мой канал в телеграме.

Комментарии (3)


  1. Alexandroppolus
    31.01.2023 12:52
    +3

    Когда-то тоже разобрался с философией TS, взглянув на него под углом теории множеств, до этого, в частности, никак не понимал смысл {...} & {...}

    Так что any — парадокс множеств, и анализировать его с этой точки зрения бессмысленно

    any - это просто "отсутствие типизации", оно вообще не входит в иерархию типов, и просто выключает проверку типов для отдельно взятого значения.

    В статье не упомянуты ещё функции. Там есть свои приколы. Например, "матерь всех функций" выглядит так: type F = (...args: never[]) => unknown - в параметрах функции наследование развернуто в обратную сторону (подробнее см. тут)


  1. Hekikai
    31.01.2023 22:57

    Автор разбирает одну из основных особенностей TS - вариантность типов. В TypeScript объекты и классы являются ковариантными в типах их свойств. Каждый сложный тип является ковариантным в своих членах, будь то: массив, объект, классы, а также возвращаемые типы функций.
    Есть одно исключение: типы параметров функции контрвариантны. Все вместе с примерами хорошо разбирается в книге "Профессиональный TypeScript". Автор - Борис Черный.
    Очень советую к ознакомлению :)


  1. aleksandr-s-zelenin
    01.02.2023 11:54

    трюэ и фалзё

    Лойс )