ТайпСкрип: Ох уж эта весёлая система типов

    Здравствуйте, меня зовут Дмитрий Карловский и недавно я, вместе с Артуром Мукминовым, проводил воркшоп, где показывал как разрабатывать сложные типофункции через тестирование. Это 2 часа сурового программирования на типах. Так что в качестве тизера, ловите разбор курьёзов тайпскриптовой системы типов.



    Отношения — это сложно


    Проверить является ли один тип подтипом другого очень просто, используя типотернарник:


    type IsAExtendsB = A extends B ? true : false

    На воркшопе мы разработали типофункцию Classify, принимающую 2 типа и возвращающую одно из 4 возможных значений:


    • [ A, '<:', B ] — A является строгим подтипом B.
    • [ A, ':>', B ] — B является строгим подтипом A.
    • [ A, '==', B ] — Оба типа являются подтипами друг друга (но не обязательно являются одинаковыми типами).
    • [ A, '!=', B ] — Ни один тип не является подтипом другого.

    Кроме того, мы запилили типофункции Equal и Assert, позволяющие сравнивать типы на равенство, независимо от того, считает ли компилятор два разных типа подтипами друг друга или нет. Assert при этом ещё и валит проверку типов, если типы вдруг не совпали.


    Всё есть объекты! Но это не точно..


    Ну и первый же прикол — Object и object — это определённо разные типы, ибо примитивные типы являются подтипами первого, но не второго:


    type boolean_is_Object = Assert<
        boolean extends Object ? true : false,
        true
    >
    
    type boolean_is_not_object = Assert<
        boolean extends object ? true : false,
        false
    >

    Однако, если мы сравним их, то выяснится, что они являются подтипами друг друга:


    type Object_vs_object = Assert<
        Classify< Object, object >,
        [ Object, '==', object ]
    >

    То есть отношение подтипизации в тайпскрипте не является транзитивным: если один тип (например, boolean) является подтипом другого (например, Object), а другой — третьего (например, object), то первый вовсе не обязательно является подтипом третьего — это надо проверять отдельно.


    На диаграмме, все объектные типы раскрашены в голубой цвет. Они являются подтипами как Object, так и object.


    Разные типы перечислений типов


    Раз уж мы заговорили про булевый тип, то нельзя не упомянуть, что он — ничто иное, как кроткий алиас для объединения пары литеральных типов:


    type boolean_is_true_or_false = Assert<
        boolean,
        true | false
    >

    С числовыми перечислениями всё в принципе аналогично:


    enum FL4 { Absurd, False, True, Unknown }
    
    type FL4_is_union = Assert<
        FL4,
        | FL4.Absurd | FL4.False | FL4.True | FL4.Unknown
    >

    И состоят они вроде бы из чисел (даже не литералов):


    type Absurd_is_number = Assert<
        Classify< FL4.Absurd, number >,
        [ FL4.Absurd, '==', number ]
    >

    Но тут тайпскрипту внезапно сносит крышу:


    type Absurd_is_never_wtf = Assert<
        Classify< FL4.Absurd, 0 >,
        [ never, '<:', 0 ]
    >

    Эй, тайпскрипт, ты куда первый тип потерял? Верни, где взял!


    type One_is_never_wtf = Assert<
        Classify< FL4.Absurd, 1 >,
        [ FL4.Absurd, ':>', never ]
    >

    Вот, спасибо, совсем другое дело!


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


    enum FL3 { Absurd, False, True }
    
    type Absurd_is_not_Absurd = Assert<
        Equal< FL3.Absurd, FL4.Absurd > | false,
        false
    >

    Ну да ладно, у нас ещё остались не разобранными строковые перечисления. Может показаться, что ведут они себя как и числовые, однако, внезапно:


    enum HappyDebugging {
        False = "True", 
        True = "False",
    }
    
    type True_extends_string = Assert<
        Classify< HappyDebugging.True, string >,
        [ HappyDebugging.True, '<:', string ]
    >

    Получается, что number является подтипом числового перечисления, а вот string подтипом строкового уже нет.


    Призраки прошлого


    В Тайпскрипте есть пара специальных типов, которые находятся на противоположных концах иерархии:


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

    Но что это маячит рядом с ними? Да это же any! С одной стороны он полностью взаимозаменяем с unknown:


    type unknown_is_any = Assert<
        unknown,
        any
    >

    Но с другой же, он как кот Шрёдингера является подтипом never (и как следствие, любого другого типа до unknown) и не является таковым одновременно:


    type any_maybe_extends_never = Assert<
        any extends never ? true : false,
        true | false
    >

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



    Весь код из статьи.


    Счастливой отладки, ребята!


    Похожие публикации

    Реклама

    Комментарии 16

      0

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

        0

        Огромная таблица получится, которую всё-равно потом вручную анализировать придётся.

        0
        Так, бежать на дарт пора или рано или наоборот прибегать из дарта?
          +2

          У Дарта же средства работы с типами совсем бедненькие.

            0

            Так ведь у дарта стандартные средства работы с типами — на уровне какого нибудь C#

              +3

              Что не идёт ни в какое сравнение ни в TS, ни с D.

                0

                Не, даже шарп в этом плане мощнее. Дарт конечно развивается, но не все фичи есть, к сожалению

              +3

              я бы сказал пора решать проблемы людей с помощью программирования, а не создавать новые)

              +5

              Спасибо за статью!


              Строго говоря, в TypeScript есть два отношения на типах: subtyping и assignability. Большая часть таких странностей относится именно к assignability, которое, как раз-таки, проверяется в conditional types. Но это скорее терминологические придирки, потому что в большинстве видимых программисту случаев используется таки assignability.

                0

                А в каких видимых случаях не используется assignability?

                  +1

                  К примеру, при overload resolution. Выбирается первая подходящая перегрузка, при этом аргументы сравниваются по отношению подтипа.


                  enum Enum { Value = 123 };
                  
                  const e: Enum = 456; // number is assignable to any enum
                  
                  declare function f(x: Enum): true;
                  declare function f(x: number): false;
                  
                  const x = f(123); // x: false
                  const y = f(Enum.Value); // y: true

                  https://www.typescriptlang.org/play?#code/KYOwrgtgBAou0G8oDUCGAbMwoF4oEYAmAZigF8BuAKCoGMB7EAZwBcpgAuWeXKAFgCsANgpQA9GKjwARsABOUAJZMoqJk0UBzEKmnpsLeqpABPdvBoATYLXSo52AGZgQtFosZRHACgAeXOEgASi4WOSxqa1t7Jxc3DxAvPy4ZeRCvDCZgajpGVihfXh8iYiDRCQKuR0zgXOY2MzwfQIgAOjRMYDLxSRNQ8OAgA


                  Я ориентировался на старую спеку, но, похоже, что в этом отношении ничего не изменилось.

                    +1

                    Кстати, f(123 as any) будет иметь тип true несмотря на то, что any — не подтип number или Enum. Но это объясняется той же спекой:


                    • когда формируется список сигнатур-кандидатов, между аргументами используется отношение assignability
                    • когда в этом списке ищется подходящая, используется отношение subtyping
                    • если подходящих нет (как в нашем случае с any), берется первая из списка
                      +1

                      Не, тогда получается, что number подтип Enum, ибо при перегрузках выбирается наиболее узкий тип. А вот если декларации поменять местами, то последнее выражение выдаёт false, то есть выбирается первая попавшаяся перегрузка, а значит компилятор считает их равнозначными.


                      enum Enum { Value = 123 };
                      
                      const e: Enum = 456; // number is assignable to any enum
                      const o: 123 = Enum.Value; // enum is assignable only to this number
                      
                      declare function f(x: number): 2;
                      declare function f(x: Enum): 1;
                      // declare function f(x: 123): 3;
                      
                      const x = f(123); // x: 2
                      const y = f(Enum.Value); // y: 2
                        0

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


                        На самом деле, даже официальная документация, несмотря на эту ремарку, часто использует просто слово compatible, не уточняя, о каком именно отношении идет речь.

                  0
                  Класс! А будет запись воркшопа? Если да, то где искать?
                    0

                    Там в самом начале ссылка же.

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

                  Самое читаемое