Это перевод, но перевод моей собственной статьи, так что не спешите убегать на «неповторимый оригинал».
Я пишу на тайпскрипте уже довольно давно. Но некоторые вопросы все еще сбивают меня с толку:
Если мне нужен объект, который реализует и
{ 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 в стройную теорию, и рисую эту великолепную диаграмму:

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

Вместо того, чтобы перечислять все элементы, множество можно определить условием. Например, «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 значений. Подробнее:
Универсальное множество — вообще все значения, которые могут появиться в JS-программе.
Тип (даже не TS-тип, просто тип) — какое-то множество JS-значений.
TS может описать некоторые типы, а некоторые — не может. Не верите? Попробуйте написать тип «все числа, кроме 0».
A extends Bиз условных типов и констрейнтов можно читать как «A — подмножество B».TS-операторы
|и&— как раз объединение и пересечение типов как множеств.Exclude<A, B>по идее моделирует разность множеств, но этот джинерик работает не для всех A и B (вспоминаем пример с числом-кроме-0,Exclude<number, 0>не работает).never— пустое множество. Доказательство: для любого AA & never = neverbA | never = A, аExclude<0, 0> = never.
Понимаю, что сложно сразу это принять, так что попробуем на примере.
Булевы типы
Сделаем вид, что в JS есть только булевы значения (я не хотел бы писать на этом). Таких значений ровно два: true и false, или, как говорил наш препод, трюэ и фалзё. Это те же яблочки, только в профиль. На булевых значениях можно составить 4 типа:
Типы-литералы
trueиfalse, в каждом — по одному значению.boolean, тип из обоих булевых значений.И
neverв роли пустого множества.
Диаграмма получится та же, что и для яблок:

Поупражняемся в телепортации из мира множеств в мир типов:
boolean— то же, чтоtrue | false(на удивление, именно так этот тип и реализован в TS)true— подмножество (или подтип)booleannever— пустое множество, значит,never— подмножество типовtrue,falseиboolean&— пересечение, значит,false & true = never,boolean & true = { true, false } | { true } = true(то есть универсальныйbooleanне влияет на пересечение),true & 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 & { 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', потому что все эти типы — подмножестваstringunknown— хорошая модель «множества всех JS-значений».anyпросто отключает проверку типов и ведет себя нелогично.never— не НИКОГДА, а пустое множество. Разextendsчитается как «является подмножеством», то вполне логично и то, что объединение с ним не меняет исходное множество, и чтоnever extendsчто угодно (ведь все 0 элементов пустого множества содержатся в чём угодно){}— не особый тип, в который влезает только пустой объект, а «штука без ограничений по типам свойств», так что число 9 вполне подходит.
На сегодня всё! Если вам было интересно, подписывайтесь на мой канал в телеграме.
Комментарии (3)

Hekikai
31.01.2023 22:57Автор разбирает одну из основных особенностей TS - вариантность типов. В TypeScript объекты и классы являются ковариантными в типах их свойств. Каждый сложный тип является ковариантным в своих членах, будь то: массив, объект, классы, а также возвращаемые типы функций.
Есть одно исключение: типы параметров функции контрвариантны. Все вместе с примерами хорошо разбирается в книге "Профессиональный TypeScript". Автор - Борис Черный.
Очень советую к ознакомлению :)
Alexandroppolus
Когда-то тоже разобрался с философией TS, взглянув на него под углом теории множеств, до этого, в частности, никак не понимал смысл {...} & {...}
any - это просто "отсутствие типизации", оно вообще не входит в иерархию типов, и просто выключает проверку типов для отдельно взятого значения.
В статье не упомянуты ещё функции. Там есть свои приколы. Например, "матерь всех функций" выглядит так: type F = (...args: never[]) => unknown - в параметрах функции наследование развернуто в обратную сторону (подробнее см. тут)