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