— Тони интересовался, — слабым голосом сказал Буш, — как согласуются, и согласуются ли вообще, божественное всемогущество и божественная всеблагость.
— И как ты ответил ему, о Джорджайя? — вопросил я.
— Я ответил… Я ответил, Господи, что из сложных словес ткут свою сеть фарисеи, а истина Духа обитает лишь в простоте. И в таком вопросе ее нет.

В. Пелевин, "Боги и механизмы"

Недавно мне задали вопрос "почему пересечение в TypeScript работает не как в теории множеств, а совсем наоборот?"

Озадачился, задумался и стал разбираться, как согласуются, и согласуются ли вообще операции "объединение" и "пересечение" в TypeScript и в теории множеств? И действительно ли пересечение в TypeScript работает прямо противоположно?

Вводные

  1. В TypeScript есть операции "объединение" (Union, обозначается как I) и "пересечение" (Intersection, обозначается как &), предназначенные для создания нового типа на базе существующих, при этом:

    1. результат объединения расширяет область сущностей, удовлетворяющих новому типу,

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

  2. В теории множеств также есть операции "объединение" и "пересечение":

    1. Объединение - множество, содержащее в себе все элементы исходных множеств (иначе - расширенное множество):
      A = {1, 2}, B = {2, 3}, тогда A ∪ B = {1, 2, 3}

    2. Пересечение - это множество, которому принадлежат только элементы, которые одновременно принадлежат всем данным множествам (иначе - суженное множество):
      A = {1, 2}, B = {2, 3}, тогда A ∩ B = {2}

Успех с объединением

Операция "объединение" и там и там работает идентично, полученному на её основе типу удовлетворяют элементы, созданные на основе любого из исходных типов, либо элементы, реализующие все типы сразу.

Для примитивов:

Код
type IPrimitiveSetA = 1 | 2;
type IPrimitiveSetB = 2 | 3;

type IUnionPrimitive = IPrimitiveSetA | IPrimitiveSetB; 

// любое значение одного из типов - ошибок нет
const unionPrimitiveOk1: IUnionPrimitive = 1;
const unionPrimitiveOk2: IUnionPrimitive = 2;
const unionPrimitiveOk3: IUnionPrimitive = 3;
// ошибка TS падает только если значения нет ни в одном исходном типе
const unionPrimitiveErr: IUnionPrimitive = 4;

Для объектов

Код
type IObjectSetA = {
    a: number;
    b: number;
    isValid: boolean;
};
type IObjectSetB = {
    x: number;
    y: number;
    isValid: boolean;
};
type IUnionObject = IObjectSetA | IObjectSetB; 

// поля из обоих исходных типов - ошибок нет
const unionObjectAll: IUnionObject = {
    a: 1,
    b: 2,
    x: 3,
    y: 4,
    isValid: true
};
// поля только из типа А - ошибок нет
const unionObjectA: IUnionObject = {
    a: 1,
    b: 2,
    isValid: true
};
// поля только из типа B - ошибок нет
const unionObjectB: IUnionObject = {
    a: 1,
    b: 2,
    isValid: true
};
// всё из одного типа и часть из другого - ошибок нет
const unionObjectAB: IUnionObject = {
    a: 1,
    b: 2,
    x: 3,
    isValid: true
};
// ни один из типов не реализован полностью - ошибка
const unionObjectErr: IUnionObject = {
    a: 1,
    x: 3
};

Проблема с пересечением

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

type ISetLiteralA = 1 | 2;
type ISetPrimitiveB = 2 | 3;

type IIntersectionPrimitive = ISetPrimitiveA & ISetPrimitiveB; 

// падает ошибка, 1 не входит в пересечение (Type '1' is not assignable to type '2')
const intersectionPrimitive1: IIntersectionPrimitive = 1; 
// ошбики нет, 2 входит в оба множества
const intersectionPrimitive2: IIntersectionPrimitive = 2;

А теперь объекты.

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

type ISetObjectA = {
    a: number;
    b: number;
    isValid: boolean;
};
type ISetObjectB = {
    x: number;
    y: number;
    isValid: boolean;
};

type IIntersectionObject = ISetObjectA & ISetObjectB;

// единственный корректный вариант
const intersectionObj:IIntersectionObject = {
    a: 1,
    b: 2,
    x: 3,
    y: 4,
    isValid: true
}

intersected Colorful and Circle to produce a new type that has all the members of Colorful and Circle - из официальной документации.

На цитате из документации можно успокоиться, сказать, что "множествам множественное, а тайпскрипту тайпскриптово". Но не может же быть одинаковое название операций простым совпадением?

Разбираемся с пересечением

Вспомним еще раз какую задачу решает операция пересечения:

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

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

Таким образом, если бы в нашем примере результирующим типом стал
type IIntersectionObject = { isValid: boolean; }, включающий только общее поле,
то объект, реализующий этот тип, не соответствовал бы ни одному из исходных типов ISetObjectA и ISetObjectB

При этом задача сужения типов также решена, так как количество возможных объектов типа IIntersectionObject несомненно меньше, чем количество объектов, удовлетворяющих типам ISetObjectA и ISetObjectB. Таким образом наличие всех полей в результирующем типе вполне логично.

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

Победа? Не совсем... Когда мы рассматривали пример с примитивами, число 2 подходило и под первый, и под второй исходный тип. А вот объект типа IIntersectionObject ни под один исходный тип не подходит, есть лишние поля. Как быть с этим?

Проверка избыточных свойств в TS

Рассмотрим пример:

type FnObjArg = { n: number };

const fn = (data: FnObjArg): number => {
    return data.n;
}
const arg = {n: 1, m: 2} as const;

// передаем в функцию объект - всё хорошо
const res1 = fn(arg);
// передаем тот же объект, создав его на месте, 
// и падает ошибка TS: 'm' does not exist in type 'FnObjArg'
const res2 = fn( { n: 1, m: 2} );

Это происходит благодаря такой особенности TS, как Excess Property Checks. В двух словах - это возможность указать для объекта больше свойств, чем есть в типе, главное при этом - передать необходимые свойства. Ошибка упадет только в следующих случаях:

type FnObjArg = { n: number, m: number };

const fn = (data: FnObjArg): number =>  data.n + data.m;

// при создании несоответствующего типу объекта при явном указании типа
// Object literal may only specify known properties, and 'p' does not exist in type 'FnObjArg'
const arg1:FnObjArg = {n: 1, p: 2};

// при передаче в функцию с типизированным аргументом объекта, в котором нет обязательных полей
const arg2 = {n: 1};
// Property 'm' is missing in type '{ n: number; }' but required in type 'FnObjArg'
fn(arg2);

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

Заключение

Итак, операция "пересечение" из теории множеств вполне согласуются с TypeScript, в чем мы достаточно подробно разобрались. Данной темы мельком касается замечательная статья "Понять TypeScript c помощью теории множеств" (глава "Интерфейсы и типы объектов"), в англоязычном stackoverflow есть открытые вопросы по теме "почему пересечение в TS работает противоположно теории множеств". Да и некоторых весьма опытных коллег вопрос поначалу ввёл в ступор, так что интерес к теме есть.

P.S. операции Union и Intersection идеально укладываются в дизъюнкцию и конъюнкцию соответственно, но это уже другая история.

Комментарии (10)


  1. NeoCode
    02.12.2023 23:51
    +1

    Очень интересно! По сути игнорирование лишних полей это такая структурная типизация? Хотя и в С++ такое есть, производные классы (с лишними полями) можно использовать вместо базовых...

    И еще, понятия "тип-сумма" и "тип-произведение" это ведь не совсем объединение и пересечение типов? Тип-сумма A+B может одновременно вместить объект только одного типа (A или B), а тип-объединение также может включать и пересечение типов (A&B), которое по сути является слиянием полей двух типов в один. Получается что тип-сумма это tagged union, а тип-объединение - tagged struct ?


    1. mayorovp
      02.12.2023 23:51
      +1

      Нет, тип-объединение - это вообще не tagged что-то там. Это буквально объединение множеств, только с типами.

      Тип-произведение тоже никакого отношения к пересечению типов не имеет, тип-произведение это кортеж, ну или тот самый struct.

      По сути игнорирование лишних полей это такая структурная типизация?

      Удивительно, в языке со структурной типизацией вы нашли структурную типизацию...


      1. NeoCode
        02.12.2023 23:51

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


      1. Alexandroppolus
        02.12.2023 23:51

        тип-произведение это кортеж, ну или тот самый struct

        Кстати, сам по себе кортеж в TS, например, [1|2, 3|4], не будет "полноценным" произведением, например, сужение типов (narrowing) не умеет с ним работать.

        Пример. Если заменить для переменной х тип T на MT, то ошибок нет.

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


        1. mayorovp
          02.12.2023 23:51

          Не вполне понятна связь между сужением типов и полноценностью произведения...


    1. litest Автор
      02.12.2023 23:51
      +1

      Хороший вопрос! Типизация в TS структурная. Сами объединения в TS - untagged, хотя можно уточнить принадлежность к типу. А вообще провести параллели между типом-суммой и типом-произведением с объединением и пересечением в TS - неплохая тема для отдельного поста.


  1. Alexandroppolus
    02.12.2023 23:51
    +1

    Добавлю ещё от себя.

    1) При пересечении объектов, если есть совпадающие ключи, то значения в этих ключах пересекаются: {a: A, b: B1} & {c: C, b: B2} = {a: A, b: B1 & B2, c: C}

    2) Пересечение функций - то же самое, что их перегрузка.

    3) Объединение функций можно рассматривать как одну функцию, аргументы которой являются пересечениями:

    type F = ((a: 1 | 2, b: 5 | 6) => void) | ((a: 2 | 3) => void);
    
    declare const f: F; // возможные вызовы: f(2, 5) и f(2, 6)

    Здесь первый параметр стал пересечением, а второй нет, но он обязателен. Суть: мы не знаем, какая именно функция из двух возможных в переменной f, но пересечения подходят для обеих.


    1. mayorovp
      02.12.2023 23:51

      При пересечении объектов, если есть совпадающие ключи, то значения в этих ключах пересекаются

      Только пересекаются всё-таки типы, а не объекты.


  1. reistr
    02.12.2023 23:51
    +1

    Спасибо, это интересно. Один момент, второй пример как будто не удачный, там же нет ничего неожиданного - тип FnObjArg требует чтобы аргумент содержал число n:

    type FnObjArg = { n: number };
    const arg2 = {m: 2} as const;
    const res = fn(arg);

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


    1. litest Автор
      02.12.2023 23:51

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