Типы, как множества. Объединение, пересечения, литералы

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

Ключевая концепция TypeScript заключается в том, что любой тип можно предствить как множество значений. Например, тип number - это множество всех чисел. Иными словами, переменной типа number можно присвоить любое числовое значение, так как каждое число входит во множество всех чисел. Чего нельзя сказать про любую строку. Не существует ни одной строки, которая бы принадлежала множеству number, но при этом любая строка принадлежит множеству string.

Как TypeScript понимает, что какое-либо выражение валидно с точки зрения типов? Рассмотрим следующий пример.

const a: number = 10;
const b: number = a;

Мы явно типизировали переменные a и b - указали, что эти переменные являются хранилищами для значений, которые принадлежат множеству number. При этом, когда мы присваиваем переменной b значение переменной a, TypeScript проверяет, является ли множество значений (тип), которые могут находиться в переменной a, подмножеством множества значений, которые можно присвоить переменной b.

Что будет происходить в таком случаи

const a = "str";
const b: number = a;

Теперь тип переменной a - это литеральный тип "str", который принадлежит множеству string. Множество number не содержит значение "str". Поэтому значение переменной a нельзя присваивать переменой b.

В этом заключается ключевая механика TypeScript. При выполнении различных операций: присваивание, сложение, вычитание, вызов функций, создание объектов и т.д. - TypeScript проверяет, является ли множество значений одного операнда подмножеством множества значений другого операнда (совместимы ли типы операндов).

Массивы и кортежи

Рассмотрим следующий код:

const a: [number, number, number] = [1, 2, 3];
const b: number[] = a;

Мы указали, что переменная a - это кортеж из 3 элементов и тип каждого элемента - это number. Переменная b - массив из любого количества элементов, при этом тип каждого элемента - это number. Получается, что кортеж [number, number, number] является подмножеством массива number[] и операция присваивания может быть выполнена. Почему? Потому что любое значение, которое можно присвоить переменной a можно присвоить и переменной b. Получается, что кортеж [number, number, number] является более строгим условием для массива number[].

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

const b: number[] = [1];
const a: [number, number, number] = b;// Error: Type 'number[]' is missing the following properties from type '[number, number, number]': 0, 1, 2(2739)

Литералы

Ранее мы утверждали следующее.

10 // имеет тип number

На самом деле это не так. Литерал 10 имеет тип 10, а не number. Это максимально точное множество значений для этого литерала - единичное множество, состоящее из значения 10. Такие типы называются литеральными или типами-литералами. В TypeScript можно указывать переменным тип, состоящий из одного единственного значения:

const a: 0 = 0;
const b: "lalaka" = "lalaka";
const c: true = true;
const d: [1, 2, 3] = [1, 2, 3];
const e: [[[[null, 15]]]] = [[[[null, 15]]]]

При этом все эти единичные множества являются подмножествами более крупных множеств. Поэтому следующие выражения не содержат ошибок:

const a1: number = a; // 0 входит во множество number
const b1: string = b; // "lalaka" входит во множесто string
const c1: boolean = c; // true входит во множество boolean
const d1: number[] = d; // рассматривали ранее
const e1: [null, number][][][] = e; // рассматривали ранее

По аналогии с массивами и кортежами - обратная операция содержит ошибку:

const a2: 0 = a1; //Error: Type 'number' is not assignable to type '0'.(2322)
const b2: "lalaka" = b1; // Error: Type 'string' is not assignable to type '"lalaka"'.(2322)
const c2: true = c1; // Error: Type 'boolean' is not assignable to type 'true'.(2322)
const d2: [1, 2, 3] = d1; // Error: Type 'number[]' is missing the following properties from type '[1, 2, 3]': 0, 1, 2(2739)
const e2: [[[null, 15]]] = e1; // Error: Property '0' is missing in type '[null, number][][][]' but required in type '[[[null, 15]]]'.(2741)

Автовывод и литеральные типы

Тип, который получится при автовыводе, зависит от того, что вы используете при объявлении переменной: const, let или var. Если вы используете const для объявления переменной с неизменяемым значением, то TypeScript выводит литеральный тип.

const a = 10; // a: 10
const b = true; // b: true
const c = "lalaka"; // c: "lalaka"
const d = null; // d: null

Однако, если использовать let или var при объявлении переменной, то TypeScript выведет более "крупный" тип.

let a = 10; // a: number
var b = true; // b: boolean
let c = "lalaka"; // c: string
var d = null; // d: any (что это узнаем позже)

Почему так происходит? Такое поведение TypeScript объясняется тем, что он выводит тип, который гарантировано бы соответствовал тому множеству значений, которое потенциально можно положить в эту переменную. В случаи объявления переменных, TypeScript опирается на JavaScript. А JavaScript в свою очередь гарантирует, что переменной, которая объявлена с использованием const нельзя будет изменить значение. Это означает, что множество значений, которые потенциально могут находиться в этой переменной, состоит из одного единственного значения и это то значение, которое задали во время инициализации переменной. Поэтому TypeScript может вывести литеральный тип.

JavaScript не может гарантировать, что переменная, объявленная через let или var, не изменит свое значение далее в программе. Соответственно и TypeScript не может гарантировать, что использование литерального типа опишет все возможные значения этой переменной.

По схожим причинам автовывод типов для значений массива (например, [1, 2, 3]) выводит массив (для примера, number[]), а не кортеж (для примера, [number, number, number]). Даже используя const в объявлении переменной, которой присваивается массив, JavaScript не может гарантировать, что внутреннее состояние массива не поменяется. Далее в программе вы сможете изменять этот массив как вам угодно: подменять значения элементов, добавлять новые элементы, удалять старые и т.д. Поэтому TypeScript не выводит литеральный тип [1, 2, 3] и даже не кортеж [number, number, number], а именно массив number[].

Any

В JavaScript одна и та же переменная может содержать в себе значение разных типов. В TypeScript такие переменные можно описать несколькими способами. Один из таких способов - использовать тип any.

any - особенный тип в TypeScript. С одной стороны этот тип является множеством абсолютно всех возможных значений. Это означает, что в переменную с типом any можно записать абсолютно любое значение.

let a: any = null;
a = 10;
a = true;
a = "lalaka";
a = [1, 2, 3];
a = [[null, true], [false, ["lalaka", "malaka"]]];

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

const a: any = "lalaka";
const b: number = a + a;
const c: true = a;
const d: [number, [string, string]] = a;

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

const a: any[] = [1, "lalaka", true];
const b = a.length // b: number = 3;
a.push("malaka");
const c = a.pop() //c: any = "malaka";

Чем чревато использование any? Для значений с типом any TypeScript выключает проверку типов, то есть работает как JavaScript. Все преимущества типизации TypeScript теряются. Поэтому использовать any нужно с осторожностью. Лучше стараться описывать тип при помощи синтаксиса TypeScript, а не использовать any. Однако бывают ситуации, когда без any не обойтись. Например, десериализация из JSON, взаимодействие с JavaScript кодом или не типизированными импортами webpack.

Объединение типов

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

Допустим, мы хотим объявить переменную, в которую будем записывать значение css свойства font-weight. Эта переменная может принимать как числовые, так и строчные значения. (Мы не будем описывать идеально точный тип для этого свойства, а решим модельную задачу в качестве демонстрации).

let fontWeight: any = "bold";
fontWeight = 600;

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

let fontWeight: number | string = "bold";
fontWeight = 600;

Переменная fontWeight имеет тип number | string. Это означает, что в эту переменную можно записывать значение из множества number ИЛИ из множества string.

Усложним условие и добавим возможность записывать в эту переменную значение null и undefined. Для этого нам нужно объединить не 2 множества, а 4: number, string, единичное множество null и единичное множество undefined.

let fontWeight: number | string | null | undefined = "bold";
fontWeight = 600;
fontWeight = null;
fontWeight = undefined;

Мы использовали операцию объединения типов 3 раза: сначала объединили тип number и string, затем результат объединения первых двух типов объединили с литеральным типом null, а затем с undefined. Получившийся тип сильно лучше, чем any, однако у него есть изъяны. Например:

let fontWeight: number | string | null | undefined = "lalaka";
fontWeight = -300;

Используя типы string и number мы не ограничиваем строки и числа, которые можно присваивать переменной. Но мы знаем, что можно использовать литеральные типы, так как они тоже являются множествами, пусть и единичными.

let fontWeight: "bold" | "bolder" | 500 | 600 | null | undefined = "bold";
fontWeight = 600;
fontWeight = null;
fontWeight = undefined;
fontWeight = 500;
fontWeight = "bolder";
fontWeight = "lalaka"; // Error: Type '"lalaka"' is not assignable to type '"bold" | "bolder" | 500 | 600 | null | undefined'.(2322)

Объединение массивов и кортежей

Как объединение работает с массивами и кортежами? Рассмотрим пример

const a: number[] | string[] = [1, 2, 3];
const b: number[] | string[] = ["lalaka"];

В данном случаи типы у переменных a и b задаются как объединение множества number[] и string[]. Это означает, что этим переменным можно присвоить или массив чисел, или массив строк. Этим переменным нельзя присвоить массив, первый элемент которого будет number, а второй string.

const a: number[] | string[] = [1, "lalaka"];
/* Error: Type '(string | number)[]' is not assignable to type 'number[] | string[]'.
  Type '(string | number)[]' is not assignable to type 'number[]'.
    Type 'string | number' is not assignable to type 'number'.
      Type 'string' is not assignable to type 'number'.(2322)
*/

Однако, такое значение можно присвоить этой переменной, если записать тип по-другому.

const a: (number | string)[] = [1, "lalaka"];

В этом случаи мы указали тип переменной a, как массив, каждый элемент которого принадлежит типу number или типу string.

Пересечение типов

По аналогии с объединением типов, в TypeScript существует операция пересечения типов. Она обозначается символом &. Если мы объявим переменную const a: number & string, то ей нужно присвоить такое значение, которое одновременно принадлежит множеству number и string, то есть является и числом, и строкой одновременно. Такого значения не существует. С точки зрения множеств мы пытаемся пересечь два множества, которые не пересекаются, и получаем пустое множество или ничего (в TypeScript есть специальный тип never, но мы поговорим про него в следующих частях).

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

Пересекать кортежи не имеет смысла, потому, что мы получим пустое множество значений. Однако у массивов есть один интересный пример. Если мы будем пересекать несколько массивов с разными типами элементов или использовать массив с пересечением простых типов - у нас всегда будет один элемент, который будет удовлетворять всем типам. Это пустой массив [].

const a: (number & string & boolean)[] = [];
const b: number[] & string[] & boolean[] = [];

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

Задания

Last updated