Type<Universe>

2.
Думаем про типы как про множества

В первой главе мы познакомились со структурной типизацией и поняли как TypeScript сравнивает объектные типы между собой. Так, благодаря этому механизму, следующий код не вызывает ошибок проверки типов:

ts
type CelestialBody = {
name: string;
};
 
type Planet = {
name: string;
satellites: string[];
};
 
const planet: Planet = {
name: 'Mars',
satellites: ['Phobos', 'Deimos'],
};
 
// Присвоение корректно, так как тип Planet содержит все необходимые поля
const body: CelestialBody = planet;

Но в ходе своей работы TypeScript сравнивает между собой не только объекты, но и все остальные типы. Поэтому во второй главе мы разберемся как такое сравнение выглядит для всех остальных типов и сформируем полное представления о том как разные типы связаны между собой

Все типы выстраиваются в иерархию

Чаще всего о типах в типизированных языках говорят как об иерархической структуре. В такой структуре один тип может быть подтипом другого, а связи между типами часто визуализируют в виде дерева:

Иерархия типов

Тип CelestialBody называют надтипом, супертипом или базовым типом. А тип Planetподтипом.

В языках с номинативной типизацией такая иерархическая связь обычно декларируется явно с помощью наследования:

ts
class Planet extends CelestialBody { ... }

Однако, в прошлой главе мы выяснили, что TypeScript использует структурную типизацию и явное декларирование связей между типами вовсе не обязательно. Поэтому куда удобнее думать про типы как про множества всех возможных значений, которые можно присвоить переменной такого типа

Представляем типы как множества

Самое простое множество, которое можно представить это множество из одного элемента:

Примеры множеств состоящих из одного элемента

Каждое из этих множеств описывает только одно возможное значение и не пересекается со всеми остальными множествами. Соответствующие этим множествам типы выглядят так:

ts
type Mars = 'Mars';
type OneHundred = 100;
type True = true;

Такие множества легко представить так как они состоят только из одного элемента. Но так же в TypeScript есть и типы, которые описывают бесконечные множества элементов, в том числе и описанные выше литеральные значения. Возьмем к примеру множество всех возможных строк:

Множество всех строк

Мы знаем, что следующий код абсолютно корректен и не вызывает ошибок типизации:

ts
let value: string;
 
value = 'Mars';
value = 'Earth';

Оба присваивания в этом примере корректны, так как литеральные строковые значения Mars и Earth являются подтипом типа string.

Но как интерпретировать это, если мы представляем типы как множества? Оказывается, что если представлять типы как множества, то и все основные концепции TypeScript могут быть интерпретированы в терминах теории множеств.

Как проверить что один тип является подтипом другого?

Одной из основных концепций в TypeScript является концепция «Присваиваемости» или «Assignability». Этот механизм непрерывно работает за кадром и предупреждает нас если мы пытаемся присвоить значение одного типа переменной совсем другого типа. А ещё скорее всего вы видели это слово в сообщениях об ошибках:

ts
type Planet = {
name: string;
};
 
// Обратите внимание на сообщение об ошибке ↴
const planet: Planet = 100;
Type 'number' is not assignable to type 'Planet'.2322Type 'number' is not assignable to type 'Planet'.

Если думать про типы как про множества значений, то принцип работы этого механизма можно изложить довольно просто: Если переменная имеет тип A, то присвоить ей значение типа B можно только тогда, когда множество B является подмножеством множества A.

В примере выше число 100 не является элементом множества Planet, поэтому такое присвоение некорректно.

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

ts
// Множество всех объектов, в которых есть поле name
type CelestialBody = {
name: string;
};
 
// Множество всех объектов, в которых ОДНОВРЕМЕННО есть поля name и satellites
type Planet = {
name: string;
satellites: string[];
};
 
const planet: Planet = {
name: 'Mars',
satellites: ['Phobos', 'Deimos'],
};
 
// Множество CelestialBody является подмножеством Planet, поэтому присвоение корректно
const body: CelestialBody = planet;

Однако, есть две ситуации, в которых TypeScript запрещает использование более широкого типа на месте более узкого. Обе связаны с использованием литеральных объектов:

  1. Передача литерального объекта в качестве аргумента функции,
  2. Присвоение литерального объекта переменной, тип которой задан явно
ts
type CelestialBody = {
name: string;
};
 
function launchSpaceship(body: CelestialBody) {
console.log(`Launching to ${body.name}. 3... 2... 1...`);
}
 
// Объект содержит поле satellites, которое не определено в типе
launchSpaceship({ name: 'Mars', satellites: ['Phobos', 'Deimos'] });
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'.
 
// Объект содержит поле satellites, которое не определено в типе
const body: CelestialBody = { name: 'Mars', satellites: ['Phobos', 'Deimos'] };
Type '{ 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'.

В таких ситуациях TypeScript достаточно умен, чтобы понять, что мы передаем лишние для объявленного типа поля. Чтобы избавиться от этих ошибок достаточно удалить из объекта всё лишнее.

Операции над типами

Примеры выше показывают как можно описать все основные типы — литералы и примитивы, объекты и массивы. Но чтобы иметь возможность писать полноценный код одних только этих типов может не хватать. Как описать что-то более сложное? Что делать если функция должна принимать в качестве аргумента и строки и числа? Как описать объект, в котором есть поля одновременно из нескольких типов?

На все эти вопросы TypeScript отвечает большим количеством возможностей по созданию новых типов на основе уже имеющихся. В этом разделе мы познакомимся с двумя такими способами — это операторы объединения (Union) и пересечения (Intersection) типов.

Объединение множеств (Union)

Результатом объединение двух множеств является новое множество, состоящие как из всех элементов первого множества, так и из всех элементов второго множества.

В TypeScript объединение типов A и B создаёт новый тип, значения которого могут иметь либо тип A, либо тип B. Для объединение типов используется вертикальная черта — A | B:

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

Элементами объединения могут быть произвольные типы, в том числе и другие объединения:

ts
type Earth = 'Earth';
type Giants = 'Jupiter' | 'Saturn';
 
type Union = Earth | Giants;
 
let planet: Union;
 
// Переменная может принимать любое из трёх значений
planet = 'Earth';
planet = 'Jupiter';
planet = 'Saturn';
 
planet = 'Pluto'
Type '"Pluto"' is not assignable to type 'Union'.2322Type '"Pluto"' is not assignable to type 'Union'.

Пересечение множеств (Intersection)

Результатом пересечения двух множеств является новое множество, состоящие только из общих для обоих множеств элементов.

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

Пересечение множеств

Чаще всего пересечения используются, чтобы объединить несколько объектных типов в один:

ts
type CelestialBody = { name: string };
type HasSatellites = { satellites: string[] };
 
type Intersection = CelestialBody & HasSatellites;
 
// Значение должно иметь оба поля
const mars: Intersection = {
name: 'Mars',
satellites: ['Phobos', 'Deimos'],
};
 
// Объект только с одним из полей не пройдет проверку типов
const venus: Intersection = { name: 'Venus' };
Type '{ 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'.

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

Тип unknown

Мы уже видели как одно множество может быть вложено в другое. Логично было бы предположить, что последовательность вложенных друг в друга множеств рано или поздно должна закончиться и привести нас к множеству, которое содержит в себе все остальные множества.

Такое множество было бы очень удобным с практической точки зрения, так как тип, который его описывает являлся бы базовым для любого другого типа в TypeScript.

В теории множеств множество, которое содержит в себе любое другое множество называется универсальным. А в TypeScript таким множеством является тип unknown:

Пересечение непересекающихся множеств

Переменной типа unknown можно присвоить абсолютно любое значение:

ts
let unknownValue: unknown;
 
unknownValue = 'Mars';
unknownValue = 100;
unknownValue = { name: 'Mars' };

Но стоит понимать, что TypeScript не позволит нам ничего сделать с таким значением:

ts
const value: unknown = { name: 'Mars' };
value.name
'value' is of type 'unknown'.18046'value' is of type 'unknown'.
 
const fn: unknown = () => 'Houston, we have a problem';
fn();
'fn' is of type 'unknown'.18046'fn' is of type 'unknown'.

TypeScript не знает настоящий тип значения, поэтому не может проверить есть ли в нём какое-то поле или можно ли вызвать его как функцию.

Тип never

Аналогично универсальному множеству, последовательность вложенных друг в друга множеств должна закончиться и с другой стороны. Это приводит нас ко множеству, которое является подмножеством любого другого множества. Или в терминах TypeScript к типу, который является подтипом любого другого типа.

Одним из естественных способов получить такой тип является пресечение двух полностью непересекающихся множеств. Говоря на языке теории множеств, мы получим «пустое множество» — множество в котором нет ни одного элемента. А в TypeScript таким типом является never:

Пересечение непересекающихся множеств
ts
type Intersection = string & number;
type Intersection = never

Тип never описывает значения, которые невозможно получить в ходе выполнения программы. Например, возвращаемое значение функции, успешное исполнение которой невозможно:

ts
function raise(): never {
throw new Error('Houston, we have a problem');
}
 
const value = raise();
const value: never

Тип never является подтипом любого другого типа и иногда с практической точки зрения это может казаться странным, так как становятся возможны следующие конструкции:

ts
function raise(): never {
throw new Error('Houston, we have a problem');
}
 
const value: number = raise();

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

Дело в том, что так как тип never описывает значение, которое невозможно получить в ходе выполнения программы, то и исполнение кода никогда до работы с этой переменной дойти не должно. Типы нужны, чтобы гарантировать корректность данных при выполнении кода. А так как интерпретатор знает, что это присвоение никогда не произойдет, то и дальнейшие типы для него не важны.

Правила работы Union и Intersection

Как и операции теории множеств, основные операции над типами в TypeScript поддаются определенным правилам. Обычно, в математике, множество значений с операциями, которые поддаются некоторым закономерностям называются алгеброй. К самым известным алгебрам можно отнести:

  1. Знакомые со школы операции умножения (×) и сложения (+)
  2. Алгебра логики c операциями конъюнкции (∧) и дизъюнкции (∨)
  3. Алгебру множеств с операциями объединения (U) и пересечения (∩) множеств

Удобство представления типов в качестве множеств подтверждается в том числе и тем, что к ним применима алгебра множеств. В частности для операций объединения и пересечения типов выполняются следующие соотношения:

1. Объединение дистрибутивно относительно пересечения

Точно так же как обычное умножение чисел дистрибутивно относительно сложения, так же и объединение типов дистрибутивно относительно пересечения. Это означает, что можно раскрыть скобки, применить объединение по отдельности к каждому из элементов пересечения, после чего пересечь результаты объединений:

ts
// (A & B) | C = (A | C) & (B | C)
 
declare let left: (A & B) | C;
declare let right: (A | C) & (B | C);
 
left = right;
right = left;

2. Пересечение дистрибутивно относительно объединения

В отличии от умножения и сложения, правило работает и в обратную сторону. Раскрыть скобки можно и при пересечении типов:

ts
// (A | B) & C = (A & C) | (B & C)
 
declare let left: (A | B) & C;
declare let right: (A & C) | (B & C);
 
left = right;
right = left;

3. Пересечение множества и его подмножества совпадает с подмножеством

Пересечение двух множеств равно их общей части, поэтому при пересечении множества и его подмножества, вы всегда получите это подмножество:

ts
type A = string;
type B = 'B';
 
type Intersection = A & B;
type Intersection = "B"
Пересечение с подмножеством

Так как unknown является надтипом любого типа, то пересечение любого типа с unknown никак его не изменяет:

ts
type A = string & unknown;
type A = string
type B = number[] & unknown;
type B = number[]
type C = Planet & unknown;
type C = Planet

А never наоборот является подтипом любого типа, поэтому пересечение с ним в результате всегда даёт never:

ts
type A = string & never;
type A = never
type B = number[] & never;
type B = never
type C = Planet & never;
type C = never

4. Объединение множества и его подмножества совпадает с самим множеством

Объединение двух множеств содержит как элементы первого множества, так и элементы второго множества. Поэтому поэтому при объединении множества и его подмножества тип остается без изменения:

ts
type A = string;
type B = 'B';
 
type Union = A | B;
type Union = string
Объединение с подмножеством

Так как unknown является надтипом любого типа, то объединение любого типа с unknown всегда равно unknown:

ts
type A = string | unknown;
type A = unknown
type B = number[] | unknown;
type B = unknown
type C = Planet | unknown;
type C = unknown

А never наоборот является подтипом любого типа, поэтому объединение любого типа с ним никак его не изменяет:

ts
type A = string | never;
type A = string
type B = number[] | never;
type B = number[]
type C = Planet | never;
type C = Planet

Чем так плох тип any?

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

ts
let planet: any = {
name: 'Mars'
};
 
// Мы можем присвоить любое значение, даже другого типа
planet.name = 100;
planet.satellites = 'Phobos';
 
// Или вообще вызвать объект как функцию
planet();
 
// Сузить тип тоже можно
const mars: Planet = planet;

Тип any одновременно является и подтипом и надтипом любого другого типа. Именно из-за этого свойства any не укладывается в интерпретацию типов как множеств и находится в стороне. Если попытаться представить any на схеме, то он одновременно будет и подмножеством и надмножеством любого другого множества:

Тип any

Это же свойство приводит нас и к тому, что объединение и пересечение всегда равны any:

ts
type Union = Planet | any;
type Union = any
type Intersection = Planet & any;
type Intersection = any

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

Особенный тип void

Ну и наконец последний не рассмотренный тип — тип void. Он особенен тем, что для формирования правильной ментальной модели о типах он не очень важен. Однако, знать о нём довольно полезно.

Смысл типа void в том, чтобы дать возможность описать возвращаемое значение функции, которое не должно быть никак использовано в месте вызова этой функции.

Любая функция в JavaScript что-то возвращает. Даже если в коде нет явного вызова return, то из функции неявно будет возвращено значение undefined. Поэтому, чтобы эмулировать это поведение, тип void является надтипом undefined:

ts
const value: void = undefined;

Но несмотря на это стоит понимать важное отличие — переменная типа void может содержать значение любого типа, а не только undefined:

ts
type VoidReturningFunction = () => void;
 
const voidReturningFunction: VoidReturningFunction = () => {
return 'Функция может возвращать любое значение, а не только undefined';
};
 
// На самом деле переменная `value` содержит строку
const value = voidReturningFunction();
const value: void

Благодаря такому поведению тип void позволяет реализовывать очень широкий класс привычных для JavaScript паттернов. Представим себе такую типизацию функции forEach:

ts
function forEach<T>(array: T[], callback: (item: T) => undefined) {
/** ... */
}

В качестве возвращаемого значения мы указали undefined, ведь нам не очень важно, что именно эта функция возвращает. Но использовать такую функцию будет затруднительно:

ts
const planets: string[] = [];
 
forEach(['Mars', 'Earth'], planet => planets.push(planet));
Type 'number' is not assignable to type 'undefined'.2322Type 'number' is not assignable to type 'undefined'.

Метод push возвращает длину полученного массива, а callback в свою очередь ожидает только undefined в качестве возвращаемого значения. Можно было бы использовать тип any, но это сделает наш код менее безопасным.

Поэтому, чтобы разрешить стандартный для JavaScript кода паттерн, более правильно воспользоваться типом void:

ts
function forEach<T>(array: T[], callback: (item: T) => void) {
/** ... */
}
 
const planets: string[] = [];
 
// Теперь мы можем возвращать из функции что угодно
forEach(['Mars', 'Earth'], planet => planets.push(planet));

Подведем итоги

В этой главе мы сформировали ментальную модель, которая помогает структурировать наши знания о типах в TypeScript и позволяет понять как разные типы соотносятся между собой.

Вот основные моменты, которые стоит запомнить:

  1. Про типы в TypeScript удобно думать как про множества значений
  2. Один тип является подтипом другого, если множество его элементов является подмножеством элементов другого типа
  3. Объединение типов (union-тип) формирует новое множество, которое состоит из элементов либо первого множества, либо элементов второго множества
  4. Пересечение типов (intersection-тип) формирует новое множество, которое состоит из общих для этих множеств элементов
  5. Пустое множество описывается типом never. Этот тип имеют значения, которые невозможно получить в ходе исполнения программы
  6. Универсальное множество описывается типом unknown. Это множество содержит в себе любое другое и значит переменной этого типа можно присвоить абсолютно любое значение
  7. Тип any одновременно является как подтипом, так и надтипом любого другого типа. Из-за этого он не поддаётся стандартным правилам операций над типами и быстро распространяется по кодовой базе

Закрепим материал?

Обычно в конце каждой главы тебя будут ждать несколько заданий, предназначенных для того чтобы закрепить изученный материал. Каждая задача состоит из нескольких частей

1. Редактор кода с описанием задачи

Описание задачи может быть как в виде функции, так и в виде generic-типа. Чтобы решить задачу необходимо заменить расставленные в коде TODO на корректные типы. Реализовывать сами функции при этом не нужно, главное чтобы типы были корректными

2. Проверка решения

Каждая задача содержит ряд проверок, которые гарантируют правильность её решения. Все вызовы функций и generic-типов не должны приводить к ошибкам типизации.

Для проверки работы кода с недопустимым вводом используется @ts-expect-error. Проверка типов пройдет только тогда, когда следующая за комментарием строка приводит к ошибке:

ts
// @ts-expect-error
const invalid: string = [];
 
// @ts-expect-error
Unused '@ts-expect-error' directive.2578Unused '@ts-expect-error' directive.
const valid: string = 'value';

3. Подсказки

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

Как понять что задача решена?

Задача считается решенной, если в редакторе не осталось ни одной ошибки проверки типов. В таком случае редактор подаст сигнал о решении задачи, а цвет рамки вокруг станет зеленым

Время решать задачи

Загружаем редактор...
Загружаем редактор...
Загружаем редактор...
Загружаем редактор...
Загружаем редактор...
Подпишись и узнавай обо всём первым
Будет приходить несколько писем в месяц, а отписаться можно в любой момент