2.Думаем про типы как про множества
В первой главе мы познакомились со структурной типизацией и поняли как TypeScript сравнивает объектные типы между собой. Так, благодаря этому механизму, следующий код не вызывает ошибок проверки типов:
tstypeCelestialBody = {name : string;};typePlanet = {name : string;satellites : string[];};constplanet :Planet = {name : 'Mars',satellites : ['Phobos', 'Deimos'],};// Присвоение корректно, так как тип Planet содержит все необходимые поляconstbody :CelestialBody =planet ;
Но в ходе своей работы TypeScript сравнивает между собой не только объекты, но и все остальные типы. Поэтому во второй главе мы разберемся как такое сравнение выглядит для всех остальных типов и сформируем полное представления о том как разные типы связаны между собой
Все типы выстраиваются в иерархию
Чаще всего о типах в типизированных языках говорят как об иерархической структуре. В такой структуре один тип может быть подтипом другого, а связи между типами часто визуализируют в виде дерева:
Тип CelestialBody
называют надтипом, супертипом или базовым типом. А тип Planet
– подтипом.
В языках с номинативной типизацией такая иерархическая связь обычно декларируется явно с помощью наследования:
tsclass Planet extends CelestialBody { ... }
Однако, в прошлой главе мы выяснили, что TypeScript использует структурную типизацию и явное декларирование связей между типами вовсе не обязательно. Поэтому куда удобнее думать про типы как про множества всех возможных значений, которые можно присвоить переменной такого типа
Представляем типы как множества
Самое простое множество, которое можно представить это множество из одного элемента:
Каждое из этих множеств описывает только одно возможное значение и не пересекается со всеми остальными множествами. Соответствующие этим множествам типы выглядят так:
tstypeMars = 'Mars';typeOneHundred = 100;typeTrue = true;
Такие множества легко представить так как они состоят только из одного элемента. Но так же в TypeScript есть и типы, которые описывают бесконечные множества элементов, в том числе и описанные выше литеральные значения. Возьмем к примеру множество всех возможных строк:
Мы знаем, что следующий код абсолютно корректен и не вызывает ошибок типизации:
tsletvalue : string;value = 'Mars';value = 'Earth';
Оба присваивания в этом примере корректны, так как литеральные строковые значения Mars
и Earth
являются подтипом типа string
.
Но как интерпретировать это, если мы представляем типы как множества? Оказывается, что если представлять типы как множества, то и все основные концепции TypeScript могут быть интерпретированы в терминах теории множеств.
Как проверить что один тип является подтипом другого?
Одной из основных концепций в TypeScript является концепция «Присваиваемости» или «Assignability». Этот механизм непрерывно работает за кадром и предупреждает нас если мы пытаемся присвоить значение одного типа переменной совсем другого типа. А ещё скорее всего вы видели это слово в сообщениях об ошибках:
tstypePlanet = {name : string;};// Обратите внимание на сообщение об ошибке ↴constType 'number' is not assignable to type 'Planet'.2322Type 'number' is not assignable to type 'Planet'.: planet Planet = 100;
Если думать про типы как про множества значений, то принцип работы этого механизма можно изложить довольно просто: Если переменная имеет тип A
, то присвоить ей значение типа B
можно только тогда, когда множество B
является подмножеством множества A
.
В примере выше число 100
не является элементом множества Planet
, поэтому такое присвоение некорректно.
Из прошлой главы мы помним, что объекты используют структурную типизацию. Но точно так же как и с примитивными типами, объектные типы описывают множество допустимых значений:
ts// Множество всех объектов, в которых есть поле nametypeCelestialBody = {name : string;};// Множество всех объектов, в которых ОДНОВРЕМЕННО есть поля name и satellitestypePlanet = {name : string;satellites : string[];};constplanet :Planet = {name : 'Mars',satellites : ['Phobos', 'Deimos'],};// Множество CelestialBody является подмножеством Planet, поэтому присвоение корректноconstbody :CelestialBody =planet ;
Однако, есть две ситуации, в которых TypeScript запрещает использование более широкого типа на месте более узкого. Обе связаны с использованием литеральных объектов:
- Передача литерального объекта в качестве аргумента функции,
- Присвоение литерального объекта переменной, тип которой задан явно
tstypeCelestialBody = {name : string;};functionlaunchSpaceship (body :CelestialBody ) {console .log (`Launching to ${body .name }. 3... 2... 1...`);}// Объект содержит поле satellites, которое не определено в типеArgument of type '{ name: string; satellites: string[]; }' is not assignable to parameter of type 'CelestialBody'. Object literal may only specify known properties, and 'satellites' does not exist in type 'CelestialBody'.2345Argument of type '{ name: string; satellites: string[]; }' is not assignable to parameter of type 'CelestialBody'. Object literal may only specify known properties, and 'satellites' does not exist in type 'CelestialBody'.launchSpaceship ({name : 'Mars',: ['Phobos', 'Deimos'] }); satellites // Объект содержит поле satellites, которое не определено в типеconstType '{ name: string; satellites: string[]; }' is not assignable to type 'CelestialBody'. Object literal may only specify known properties, and 'satellites' does not exist in type 'CelestialBody'.2322Type '{ name: string; satellites: string[]; }' is not assignable to type 'CelestialBody'. Object literal may only specify known properties, and 'satellites' does not exist in type 'CelestialBody'.body :CelestialBody = {name : 'Mars',: ['Phobos', 'Deimos'] }; satellites
В таких ситуациях TypeScript достаточно умен, чтобы понять, что мы передаем лишние для объявленного типа поля. Чтобы избавиться от этих ошибок достаточно удалить из объекта всё лишнее.
Операции над типами
Примеры выше показывают как можно описать все основные типы — литералы и примитивы, объекты и массивы. Но чтобы иметь возможность писать полноценный код одних только этих типов может не хватать. Как описать что-то более сложное? Что делать если функция должна принимать в качестве аргумента и строки и числа? Как описать объект, в котором есть поля одновременно из нескольких типов?
На все эти вопросы TypeScript отвечает большим количеством возможностей по созданию новых типов на основе уже имеющихся. В этом разделе мы познакомимся с двумя такими способами — это операторы объединения (Union
) и пересечения (Intersection
) типов.
Объединение множеств (Union)
Результатом объединение двух множеств является новое множество, состоящие как из всех элементов первого множества, так и из всех элементов второго множества.
В TypeScript объединение типов A
и B
создаёт новый тип, значения которого могут иметь либо тип A
, либо тип B
. Для объединение типов используется вертикальная черта — A | B
:
Элементами объединения могут быть произвольные типы, в том числе и другие объединения:
tstypeEarth = 'Earth';typeGiants = 'Jupiter' | 'Saturn';typeUnion =Earth |Giants ;letplanet :Union ;// Переменная может принимать любое из трёх значенийplanet = 'Earth';planet = 'Jupiter';planet = 'Saturn';Type '"Pluto"' is not assignable to type 'Union'.2322Type '"Pluto"' is not assignable to type 'Union'.= 'Pluto' planet
Пересечение множеств (Intersection)
Результатом пересечения двух множеств является новое множество, состоящие только из общих для обоих множеств элементов.
В TypeScript пересечение типов A
и B
создаёт новый тип, значения которого должны одновременно быть совместимы с типами A
и B
. Для пересечения типов используется амперсанд — A & B
:
Чаще всего пересечения используются, чтобы объединить несколько объектных типов в один:
tstypeCelestialBody = {name : string };typeHasSatellites = {satellites : string[] };typeIntersection =CelestialBody &HasSatellites ;// Значение должно иметь оба поляconstmars :Intersection = {name : 'Mars',satellites : ['Phobos', 'Deimos'],};// Объект только с одним из полей не пройдет проверку типовconstType '{ name: string; }' is not assignable to type 'Intersection'. Property 'satellites' is missing in type '{ name: string; }' but required in type 'HasSatellites'.2322Type '{ name: string; }' is not assignable to type 'Intersection'. Property 'satellites' is missing in type '{ name: string; }' but required in type 'HasSatellites'.: venus Intersection = {name : 'Venus' };
Но это далеко не единственное применение пересечений, так как использовать их можно не только с объектами, но и с любыми другими типами. С некоторыми примерами полезных пересечений мы познакомимся уже в этой главе.
Тип unknown
Мы уже видели как одно множество может быть вложено в другое. Логично было бы предположить, что последовательность вложенных друг в друга множеств рано или поздно должна закончиться и привести нас к множеству, которое содержит в себе все остальные множества.
Такое множество было бы очень удобным с практической точки зрения, так как тип, который его описывает являлся бы базовым для любого другого типа в TypeScript.
В теории множеств множество, которое содержит в себе любое другое множество называется универсальным. А в TypeScript таким множеством является тип unknown
:
Переменной типа unknown
можно присвоить абсолютно любое значение:
tsletunknownValue : unknown;unknownValue = 'Mars';unknownValue = 100;unknownValue = {name : 'Mars' };
Но стоит понимать, что TypeScript не позволит нам ничего сделать с таким значением:
tsconstvalue : unknown = {name : 'Mars' };'value' is of type 'unknown'.18046'value' is of type 'unknown'.value .name constfn : unknown = () => 'Houston, we have a problem';'fn' is of type 'unknown'.18046'fn' is of type 'unknown'.(); fn
TypeScript не знает настоящий тип значения, поэтому не может проверить есть ли в нём какое-то поле или можно ли вызвать его как функцию.
Тип never
Аналогично универсальному множеству, последовательность вложенных друг в друга множеств должна закончиться и с другой стороны. Это приводит нас ко множеству, которое является подмножеством любого другого множества. Или в терминах TypeScript к типу, который является подтипом любого другого типа.
Одним из естественных способов получить такой тип является пресечение двух полностью непересекающихся множеств. Говоря на языке теории множеств, мы получим «пустое множество» — множество в котором нет ни одного элемента. А в TypeScript таким типом является never
:
tstypeIntersection = string & number;
Тип never
описывает значения, которые невозможно получить в ходе выполнения программы. Например, возвращаемое значение функции, успешное исполнение которой невозможно:
tsfunctionraise (): never {throw newError ('Houston, we have a problem');}constvalue =raise ();
Тип never
является подтипом любого другого типа и иногда с практической точки зрения это может казаться странным, так как становятся возможны следующие конструкции:
tsfunctionraise (): never {throw newError ('Houston, we have a problem');}constvalue : number =raise ();
С одной стороны, так как тип never
является подтипом типа number
, то такое присвоение корректно. Но с другой стороны очевидно, что замена значения value
на never
сделает дальнейшее исполнение кода невозможным, так как тип never
не содержит ни одного значения. Почему же это работает?
Дело в том, что так как тип never
описывает значение, которое невозможно получить в ходе выполнения программы, то и исполнение кода никогда до работы с этой переменной дойти не должно. Типы нужны, чтобы гарантировать корректность данных при выполнении кода. А так как интерпретатор знает, что это присвоение никогда не произойдет, то и дальнейшие типы для него не важны.
Правила работы Union и Intersection
Как и операции теории множеств, основные операции над типами в TypeScript поддаются определенным правилам. Обычно, в математике, множество значений с операциями, которые поддаются некоторым закономерностям называются алгеброй. К самым известным алгебрам можно отнести:
- Знакомые со школы операции умножения (×) и сложения (+)
- Алгебра логики c операциями конъюнкции (∧) и дизъюнкции (∨)
- Алгебру множеств с операциями объединения (U) и пересечения (∩) множеств
Удобство представления типов в качестве множеств подтверждается в том числе и тем, что к ним применима алгебра множеств. В частности для операций объединения и пересечения типов выполняются следующие соотношения:
1. Объединение дистрибутивно относительно пересечения
Точно так же как обычное умножение чисел дистрибутивно относительно сложения, так же и объединение типов дистрибутивно относительно пересечения. Это означает, что можно раскрыть скобки, применить объединение по отдельности к каждому из элементов пересечения, после чего пересечь результаты объединений:
ts// (A & B) | C = (A | C) & (B | C)declare letleft : (A &B ) |C ;declare letright : (A |C ) & (B |C );left =right ;right =left ;
2. Пересечение дистрибутивно относительно объединения
В отличии от умножения и сложения, правило работает и в обратную сторону. Раскрыть скобки можно и при пересечении типов:
ts// (A | B) & C = (A & C) | (B & C)declare letleft : (A |B ) &C ;declare letright : (A &C ) | (B &C );left =right ;right =left ;
3. Пересечение множества и его подмножества совпадает с подмножеством
Пересечение двух множеств равно их общей части, поэтому при пересечении множества и его подмножества, вы всегда получите это подмножество:
tstypeA = string;typeB = 'B';typeIntersection =A &B ;
Так как unknown
является надтипом любого типа, то пересечение любого типа с unknown
никак его не изменяет:
tstypeA = string & unknown;typeB = number[] & unknown;typeC =Planet & unknown;
А never
наоборот является подтипом любого типа, поэтому пересечение с ним в результате всегда даёт never
:
tstypeA = string & never;typeB = number[] & never;typeC =Planet & never;
4. Объединение множества и его подмножества совпадает с самим множеством
Объединение двух множеств содержит как элементы первого множества, так и элементы второго множества. Поэтому поэтому при объединении множества и его подмножества тип остается без изменения:
tstypeA = string;typeB = 'B';typeUnion =A |B ;
Так как unknown
является надтипом любого типа, то объединение любого типа с unknown
всегда равно unknown
:
tstypeA = string | unknown;typeB = number[] | unknown;typeC =Planet | unknown;
А never
наоборот является подтипом любого типа, поэтому объединение любого типа с ним никак его не изменяет:
tstypeA = string | never;typeB = number[] | never;typeC =Planet | never;
Чем так плох тип any?
Согласно документации тип any
— это тип, который можно использовать для того, чтобы проверка типов в конкретном месте не вызывала ошибок. Например, весь следующий код не вызывает никаких ошибок проверки типов:
tsletplanet : any = {name : 'Mars'};// Мы можем присвоить любое значение, даже другого типаplanet .name = 100;planet .satellites = 'Phobos';// Или вообще вызвать объект как функциюplanet ();// Сузить тип тоже можноconstmars :Planet =planet ;
Тип any
одновременно является и подтипом и надтипом любого другого типа. Именно из-за этого свойства any
не укладывается в интерпретацию типов как множеств и находится в стороне. Если попытаться представить any
на схеме, то он одновременно будет и подмножеством и надмножеством любого другого множества:
Это же свойство приводит нас и к тому, что объединение и пересечение всегда равны any
:
tstypeUnion =Planet | any;typeIntersection =Planet & any;
Таким образом, any
при взаимодействии с любыми другими типами поглощает их, из-за чего any
очень быстро распространяется по кодовой базе и делает ее значительно менее безопасной.
Особенный тип void
Ну и наконец последний не рассмотренный тип — тип void
. Он особенен тем, что для формирования правильной ментальной модели о типах он не очень важен. Однако, знать о нём довольно полезно.
Смысл типа void
в том, чтобы дать возможность описать возвращаемое значение функции, которое не должно быть никак использовано в месте вызова этой функции.
Любая функция в JavaScript что-то возвращает. Даже если в коде нет явного вызова return
, то из функции неявно будет возвращено значение undefined
. Поэтому, чтобы эмулировать это поведение, тип void
является надтипом undefined
:
tsconstvalue : void =undefined ;
Но несмотря на это стоит понимать важное отличие — переменная типа void
может содержать значение любого типа, а не только undefined
:
tstypeVoidReturningFunction = () => void;constvoidReturningFunction :VoidReturningFunction = () => {return 'Функция может возвращать любое значение, а не только undefined';};// На самом деле переменная `value` содержит строкуconstvalue =voidReturningFunction ();
Благодаря такому поведению тип void
позволяет реализовывать очень широкий класс привычных для JavaScript паттернов. Представим себе такую типизацию функции forEach:
tsfunctionforEach <T >(array :T [],callback : (item :T ) => undefined) {/** ... */}
В качестве возвращаемого значения мы указали undefined
, ведь нам не очень важно, что именно эта функция возвращает. Но использовать такую функцию будет затруднительно:
tsconstplanets : string[] = [];Type 'number' is not assignable to type 'undefined'.2322Type 'number' is not assignable to type 'undefined'.forEach (['Mars', 'Earth'],planet =>planets .push (planet ));
Метод push
возвращает длину полученного массива, а callback
в свою очередь ожидает только undefined
в качестве возвращаемого значения. Можно было бы использовать тип any
, но это сделает наш код менее безопасным.
Поэтому, чтобы разрешить стандартный для JavaScript кода паттерн, более правильно воспользоваться типом void
:
tsfunctionforEach <T >(array :T [],callback : (item :T ) => void) {/** ... */}constplanets : string[] = [];// Теперь мы можем возвращать из функции что угодноforEach (['Mars', 'Earth'],planet =>planets .push (planet ));
Подведем итоги
В этой главе мы сформировали ментальную модель, которая помогает структурировать наши знания о типах в TypeScript и позволяет понять как разные типы соотносятся между собой.
Вот основные моменты, которые стоит запомнить:
- Про типы в TypeScript удобно думать как про множества значений
- Один тип является подтипом другого, если множество его элементов является подмножеством элементов другого типа
- Объединение типов (union-тип) формирует новое множество, которое состоит из элементов либо первого множества, либо элементов второго множества
- Пересечение типов (intersection-тип) формирует новое множество, которое состоит из общих для этих множеств элементов
- Пустое множество описывается типом
never
. Этот тип имеют значения, которые невозможно получить в ходе исполнения программы - Универсальное множество описывается типом
unknown
. Это множество содержит в себе любое другое и значит переменной этого типа можно присвоить абсолютно любое значение - Тип
any
одновременно является как подтипом, так и надтипом любого другого типа. Из-за этого он не поддаётся стандартным правилам операций над типами и быстро распространяется по кодовой базе
Закрепим материал?
Обычно в конце каждой главы тебя будут ждать несколько заданий, предназначенных для того чтобы закрепить изученный материал. Каждая задача состоит из нескольких частей
1. Редактор кода с описанием задачи
Описание задачи может быть как в виде функции, так и в виде generic-типа. Чтобы решить задачу необходимо заменить расставленные в коде TODO
на корректные типы. Реализовывать сами функции при этом не нужно, главное чтобы типы были корректными
2. Проверка решения
Каждая задача содержит ряд проверок, которые гарантируют правильность её решения. Все вызовы функций и generic-типов не должны приводить к ошибкам типизации.
Для проверки работы кода с недопустимым вводом используется @ts-expect-error
. Проверка типов пройдет только тогда, когда следующая за комментарием строка приводит к ошибке:
ts// @ts-expect-errorconstinvalid : string = [];Unused '@ts-expect-error' directive.2578Unused '@ts-expect-error' directive.// @ts-expect-error constvalid : string = 'value';
3. Подсказки
Если в ходе решения задачи понадобится помощь или захочется свериться с эталонным решением, то каждая задача содержит одну или несколько подсказок, а так же полное решение
Как понять что задача решена?
Задача считается решенной, если в редакторе не осталось ни одной ошибки проверки типов. В таком случае редактор подаст сигнал о решении задачи, а цвет рамки вокруг станет зеленым