Бесплатный курс по TypeScript. Зарегистрируйтесь для отслеживания прогресса →

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

В этом уроке мы разберем связь между типами, которая выстраивается в иерархию.

Типы как подмножества

Рассмотрим пример ошибки Type X is not assignable to type Y в функции для сортировки элементов. Допустим, у нас уже написана функция sort. И чтобы описать только ее типы, воспользуемся ключевым словом declare:

type ComparatorCallback = (item1: number, item2: number, index: number) => -1 | 0 | 1
declare function sort(arr: Array<number>, callback: ComparatorCallback): Array<number>

const arr = [1, 2, 3];
const comparator = (item1: number, item2: number) => Math.sign(item1 - item2);

sort(arr, comparator) // Error: Type 'number' is not assignable to type '0 | 1 | -1'.

Проверка типов выдала ошибку: объединение литеральных типов 0 | 1 | -1 не совместимо с типом number. Можно подумать, что система типов ошибается, и стоит использовать any. Но если мы подумаем о литеральных числовых типах как о подмножествах number, все становится на свои места.

На этом примере мы снова можем увидеть связь типов с теорией множеств. Множество A является подмножеством B, если любой элемент, который принадлежит A, также принадлежит B. Так, с помощью операции объединения, мы получаем связи между типами, которые выстраиваются в иерархию вложенных множеств - иерархию типов.

Литеральные типы

Напомним, что литеральные типы существуют для четырех типов данных: boolean, string, number, BigInt. В итоге любой литеральный тип можно присвоить переменной соответствующего типа:

let num: number = 1;
const two: 2 = 2;
const notTrue: false = false;

num = two;
num = notTrue; // Type 'boolean' is not assignable to type 'number'.

Здесь 2 используется как литеральный тип, который представляет из себя множество из одного элемента — двойки.

Анализатор успешно пропустил присваивание литерального типа числа к number, но литеральный boolean тип мы уже не смогли присвоить. Чтобы решить эту проблему, можно использовать объединение типов number | boolean. Но если мы не уверены, что может быть присвоено, нам пришлось бы делать объединение с потенциально огромным числом типов.

В этом случае нам на помощь приходит тип unknown.

unknown

Тип unknown — это надмножество всех доступных типов. Он позволяет присвоить переменной значение произвольного типа:

let unknownValue: unknown = 1;

unknownValue = 2; // OK
unknownValue = false; // OK
unknownValue = 'string'; // OK

Может показаться, что тип unknown работает так же, как any. Однако между ними есть принципиальное различие. Тип any отключает проверку типов и позволяет выполнять любые операции со значением, например, обращаться к свойствам переменной. Тип unknown запрещает это и требует предварительной проверки типа переменной, либо приведения к нужному типу. Давайте рассмотрим это на примере:

let unknownValue: unknown;

unknownValue = 'string';
unknownValue.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'unknown'.

Здесь мы смогли присвоить переменной unknownValue строку, но не можем вызвать метод toUpperCase(), потому что компилятор не знает, что в переменной находится строка. Для того, чтобы вызвать метод, нужно сначала проверить тип переменной.

Другим важным свойством является то, что при объединении любых типов с unknown мы всегда получаем unknown:

type UnionWithUnknown = unknown | number | boolean;

Такое поведение объясняется тем, что unknown — это надмножество всех типов, поэтому любое объединение с ним дает его само. Исключением тут является any, который и в этом случае отключает проверку типов и не подчиняется модели типов как множеств.

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

never

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

let neverValue: never;
const two: 2 = 2;

neverValue = two; // Type 'number' is not assignable to type 'never'

В этом случае мы получили ошибку, потому что never — это пустое множество, которое не содержит ни одного элемента. Поэтому никакое значение не может быть присвоено переменной neverValue.

Множества типов

Из текущих знаний мы можем составить следующую картинку множеств типов TypeScript:

NumberOrString

В множество number также входят все объединения литеральных типов чисел, а в множество string — литеральных строк:

type NumberUnion = -2 | -1 | 1 | 2

const one: NumberUnion = 1;
const num: number = one;

type StringUnion = 'a' | 'b' | 'c' | 'd'

const aChar: StringUnion = 'a';
const str: string = aChar;

Такое подмножество типов называют подтипом, а само множество супертипом.

Взаимосвязи подтипов и супертипов — ключевая концепция любого статически типизированного языка. Они образуют иерархию типов. Это становится особо важно, когда мы хотим привести один тип к другому.

Приведение типов

Рассмотрим различные варианты приведения типов:

let num = 1; // Неявное восходящее приведение
let one: number = 1; // Явное восходящее приведение

let two = num as 2; // Явное нисходящее приведение

let three = 3 as const; // Приведение к литеральному типу — нисходящее

Когда мы присваиваем значение в переменную или передаем аргументы в функцию, TypeScript пытается сделать восходящее приведение — от подтипа к базовому. Также можно явно задать восходящее приведение. Мы уже пользовались этой возможностью, чтобы проверить, возможно ли привести один тип к другому или указать явно, переменную какого типа мы ожидаем.

Приведение базового типа к подтипу делается явно с помощью ключевого слова as. При таком поведении TypeScript принимает приведение типов за истину. В некоторых случаях это может привести к ошибке. Поэтому нисходящее приведение считается небезопасным. К такому коду нужно пристально присмотреться.

Разберем еще один пример:

const args = [8, 5]; // args: number[]
const angle = Math.atan2(...args); // error! A spread argument must either have a tuple type or be passed to a rest parameter.
console.log(angle);

Здесь компилятор определяет переменной args как тип number[] — массив с любым количеством числовых элементов. Компилятор расширил возможные значения в массиве, не смотря на то, что мы указали всего два элемента в массиве. Это и есть неявное восходящее приведение типа, когда компилятор приводит к более общему типу.

По этой причине возникает ошибка, потому что метод Math.atan2() ожидает два аргумента, а тип переменной args может содержать любое количество элементов. Исправим это с помощью ключевого слова as:

const args = [8, 5] as const; // readonly [8, 5]
const angle = Math.atan2(...args); // okay
console.log(angle);

Теперь компилятор определяет тип для переменной args как литеральный тип [8, 5]. Хоть он и является множеством типа number[], но это уже более строгий тип, который является массивом из двух конкретных чисел, поэтому ошибки не будет. Такое приведение называется "нисходящим", так как мы от более широкого типа приводим к более узкому типу, содержащему меньшее количество возможных значений.

Задание

Реализуйте функцию getUserFriends(userResponseJSON, userId), которая принимает на вход JSON-строку и userId пользователя. JSON содержит массив пользователей users и с массив друзей friends в виде пар [userId, userId]. Функция возвращает список друзей пользователя по переданному userId`.

Если пользователь с указанным id не найден, то функция должна вернуть пустой массив.

const userJson = JSON.stringify({
  users: [
    { id: 1, name: 'John', age: 20 },
    { id: 2, name: 'Mary', age: 21 },
  ],
  friends: [
    [1, 2],
  ],
});

getUserFriends(userJson, 1); // [{ id: 2, name: 'Mary', age: 21 }]
getUserFriends(userJson, 2); // [{ id: 1, name: 'John', age: 20 }]
getUserFriends(userJson, 3); // []
Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

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

Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.

Мой код отличается от решения учителя 🤔

Это нормально 🙆, в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.

В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.

Прочитал урок — ничего не понятно 🙄

Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.

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

Полезное


Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе