В сентябре этого года Microsoft анонсировал TypeScript 4.9 beta. В бета-версии появились любопытные нововведения и исправления: новый оператор, оптимизация производительности, улучшения существующих типов… 

Меня зовут Екатерина Семенова, я — фронтенд-разработчик в Surf. Давайте вместе разберём самые интересные фичи этого анонса. 

Новый оператор satisfies

TL;DR Позволяет делать неоднородные наборы типов более гибкими.

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

type ComponentKey = 'component1' | 'component2' | 'component3';

const data: Record<ComponentKey, number | string> = {
    component1: 0,
    component2: '',
    component3: 42,
};

const firstResult = data.component1 + 42;

const secondResult = data.component2.toUpperCase();

Если мы попытаемся работать с объектом data далее, неизбежно наткнёмся на ошибки типов:

Operator '+' cannot be applied to types 'string | number' and 'number'.

Property 'toUpperCase' does not exist on type 'string | number'.
Property 'toUpperCase' does not exist on type 'number'.

Это объясняется тем, что number | string — это объединение. TypeScript разрешает операцию над объединением только в том случае, если она действительна для каждого члена объединения.

TypeScript 4.9 beta предлагает выход: использовать новый оператор satisfies, который позволит безболезненно указывать, какому именно типу удовлетворяет объект.

type ComponentKey = 'component1' | 'component2' | 'component3';

const data = {
    component1: 0,
    component2: '',
    component3: 42,
} satisfies Record<ComponentKey, number | string>;

const firstResult = data.component1 + 42;

const secondResult = data.component2.toUpperCase();

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

type ComponentKey = 'component1' | 'component2' | 'component3';

const data = {
    component1: 0,
    component2: 44,
    component3: 42,
} satisfies Record<ComponentKey, number | string>;

const firstResult = data.component1 + 42;

const secondResult = data.component2.toUpperCase();

И, как и ожидалось, увидим ошибку:

Property 'toUpperCase' does not exist on type 'number'.

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

Подробнее про оператор, примеры использования, спорные моменты 

Умный in

TL;DR В операторе in станет меньше ошибок при сужении типов.

Оператор in в JavaScript проверяет, существует ли свойство у объекта. Это удобно для данных, чей тип мы не знаем: например, для данных файлов конфигураций.

В TypeScript оператор in часто используется, чтобы проверить, входит ли свойство в объект определённого типа. Пример:

interface Bird {
    fly(): void;
    layEggs(): void;
}

interface Fish {
    swim(): void;
    layEggs(): void;
}

function isFish(creature: Fish | Bird): creature is Fish {
    return 'swim' in creature;
}

isFish — это предикат, который определяет принадлежность параметра creature типу Fish. Для этого выполняется проверка: если свойство swim содержится в объекте, то очевидно, что параметр creature относится к типу Fish.

Возвращаемый тип функции isFish помогает при вызове функции неявно привести параметр creature к нужному типу, по сути сузив его:

function action(creature: Fish | Bird) {
    if (isFish(creature)) creature.swim(); // Это Fish
    else creature.fly(); // А это Bird
}

Но что, если тип объекта, в котором проверяется наличие свойства, неизвестен? Что будет, если мы попробуем проверить, существует ли свойство в объекте с заранее неизвестным типом? Рассмотрим следующий пример, более приближенный к жизни:

function getConfigVersion(config: unknown) {
    if (config && typeof config === 'object') {
        if ('version' in config && typeof config.version === 'string') {
            return config.version;
        }
    }

    return undefined;
}

В этом примере мы пытаемся получить свойство version из параметра, чей тип невозможно предсказать заранее. В первой проверке неявно приводим config к типу object, во второй убеждаемся, что свойство version типа string есть в объекте. Но почему-то получаем ошибки:

Property 'version' does not exist on type 'object'.
Property 'version' does not exist on type 'object'.

В чем дело? Оказывается, оператор in строго ограничен типом, который фактически определяет проверяемую переменную. Тип переменной config уже известен как object, соответственно, ни к чему другому приведение в данном случае невозможно: дальнейшая работа с переменной затруднительна.

TypeScript 4.9-beta исправляет это поведение. Вместо того, чтобы оставлять объект «как есть», добавляет к типу Record<"property-key-being-checked", unknown> . Переменная config после всех проверок будет иметь тип object & Record<"version", unknown>, что позволит функции выполниться без ошибок.

Больше информации по теме — в гитхабе Microsoft

Not a number

TL;DR Прямое сравнение с NaN теперь запрещено.

NaN — это специальное числовое значение, обозначающее что угодно, но не число. NaN — единственное значение в JavaScript, которое при сравнении с самим собой с помощью оператора строгого равенства (===) дает false. То же происходит при сравнении с любыми другими значениями. То есть ничто не может быть равно NaN — даже NaN!

Примеры типов операций, которые возвращают NaN:

  1. Неудачное преобразование чисел. Например, parseInt("not_a_number").

  2. Математическая операция, результат которой не является действительным числом. Например, Math.sqrt(-1).

  3. Метод или выражение, операнд которого является NaN или приводится к нему. Например, 42 * NaN.

То есть наткнуться на NaN в своем коде вполне реально. Это приводит к тому, что разработчики могут случайно сравнить результат операции напрямую с NaN:

parseInt(someValue) !== NaN

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

Решение проблемы — запрет на прямое сравнение с NaN. В бета-версии TypeScript 4.9 сравнение возможно только через специальный метод Number.isNaN, который позволит избежать логических ошибок при сравнении.

Теперь разработчики видят ошибку:

TS2845: This condition will always return 'true'.  
Did you mean '!Number.isNaN(...)'?

Отслеживание изменений

TL;DR Изменена стратегия по умолчанию для отслеживания изменений — механизм File System Events.

В более ранних версиях TypeScript для отслеживания изменений использовалась стратегия опроса — pooling: периодическая проверка состояния файла на наличие обновлений. В этом подходе есть плюсы и минусы. Например, такой способ отслеживания изменений считается более надежным и предсказуемым на разных платформах и файловых системах. Однако если кодовая база большая, то отслеживание изменений путём опроса может серьезно повысить нагрузку на ЦП и привести к перерасходу ресурсов.

В TypeScript 4.9 стратегией по умолчанию станет механизм событий файловой системы — File System Events. Он основывается на подписке на событие изменения файлов и выполнение кода только тогда, когда это событие произошло. Таким образом, отпадает необходимость периодических опросов. Для большинства разработчиков это нововведение должно обеспечить гораздо более комфортный опыт при работе в режиме --watch или при работе с редактором на основе TypeScript.

Настроить отслеживание изменений по-прежнему можно с помощью переменных среды и watchOptions. 

Подробнее — в хендбуке TypeScript 

Что дальше

Команда разработчиков продолжает разработку TypeScript 4.9 и в начале ноября планирует выпустить окончательный релиз. Ждём!

А пока что можно установить себе бета-версию и попробовать новые фичи:

npm install -D typescript@beta

Следить за планом итераций TypeScript 4.9.

Почитать про другие изменения бета-версии.

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


  1. NeoCode
    28.10.2022 09:56

    Насколько я понимаю, то что Nan!=Nan - требование IEEE 754. Но почему так сделали? ИМХО NaN это обычный none в опциональном типе, а создатели стандарта чисел с плавающей точкой все зачем-то усложнили.


    1. Djaler
      28.10.2022 10:01
      +3

      NaN и отсутствие числа - разные вещи


      1. 0xd34df00d
        28.10.2022 20:59
        +1

        Чем разные?


        То есть, чем структура, описываемая IEEE754, лучше структуры, где число — это какой-нибудь Maybe Float', где у Float' не бывает NaN, и где операции определены как лифтанутые в Maybe?


        1. m03r
          28.10.2022 22:31

          Maybe Float всем лучше! Обнаружил тут крейт Noisy Float, не такой уж непопулярный, который как раз-таки с безопасным float

          UPD: безопасный он довольно условно: просто паникует при любом ненормальном числе. Видимо, это поведение более востребовано, чем аккуратный Option вокруг почти всего


    1. Fr0sT-Brutal
      28.10.2022 10:01
      +1

      Возможно, потому же, что в SQL NULL <> NULL


    1. PaulIsh
      28.10.2022 10:33
      +3

      NaN получается в результате разных операций. Например вы что-то поделили на 0 и получили NaN и вы взяли квадратный корень из -1 и получили NaN. Язык не может гарантировать равенство результатов, которые он не может выразить числом.


      1. 0xd34df00d
        28.10.2022 21:01

        Не понял аргумент. Почему он не может гарантировать равенство результатов? Какая разница, каким путем получен результат?


        1. faiwer
          28.10.2022 21:31
          +3

          Я думаю имелось ввиду, что /0=∞, а -1^1/2 — мнимое число. Т.е результаты разные, но представлены одним и тем же NaN, который эдакий "всё плохо". И чтобы не сравнивали разные виды "всё плохо" с друг другом...


          1. 0xd34df00d
            28.10.2022 22:08
            +2

            Если sqrt : Float -> Float, то результат sqrt (-1) непредставим, и надо делать Maybe Float независимо от причин.


        1. PaulIsh
          29.10.2022 20:30
          +1

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

          if (1/0 == Math.sqrt(-1)) {
             console.log('1/0 equal to sqrt(-1)');
          }

          А теперь замените левую и правую часть сравнения на NaN. Почему в этом результат должен отличаться.


          1. 0xd34df00d
            29.10.2022 21:39

            С точки зрения математики у меня справа i, с чего бы мне его заменять на NaN?


            1. PaulIsh
              30.10.2022 07:06

              Потому что JS не умеет комплексные числа, и так как они не могут быть выражены через тип number, то мы имеем NaN. И поскольку JS не может корректно представить левую и правую часть типом number он заменяет их на NaN.

              Если в перспективе number расширят на комплексные числа или введут новый тип, то и sqrt(-1) перестанет быть NaN, но пока так.


              1. 0xd34df00d
                30.10.2022 10:18

                Вы у меня спросили про точку зрения математики, я точку зрения математики и ответил.

                А 1/0 вообще не число, и тайпчекаться не должно.


    1. m03r
      28.10.2022 10:50

      Был бы явный Option, было бы логично: (Some(5) == Some(5)) == Some(true), при этом (Some(5) == None) == None) и (None == None) = None).

      Но Option тут половинчатый, и такое решение выглядит довольно логично. В конце концов, NaN представляет разные ситуации не-чисел, например, parseInt("blah") и "".charCodeAt(1). Было бы странно говорить, что они равны в контексте сравнения чисел.


      1. 0xd34df00d
        28.10.2022 21:03
        +1

        Был бы явный Option, было бы логично: (Some(5) == Some(5)) == Some(true), при этом (Some(5) == None) == None) и (None == None) = None).

        Зачем так делать? Почему не просто True в первом случае, False во втором и True в третьем?


        1. m03r
          28.10.2022 22:25

          Хороший вопрос! Для меня сравнение тут возможно трёх видов (Number во всех трёх случаях включает не NaN, Option<Number> — включает):

          1. Option<Number> -> Option<Number> -> Option<Bool>, когда сравниваются только числа, а не-числа приводят к принципиально другому результату. Это как в SQL с nullable-типами: NULL = NULL даёт NULL

          2. Option<Number> -> Option<Number> -> Bool по типу SQL-ного IS NOT DISTINCT FROM (оно же <=> в MySQL). Это Ваш вариант

          3. Option<Number> -> Option<Number> -> Bool , в виде модификации первого варианта, когда Some(true) приравнивается к true, а всё остальное — к false. Это вариант из IEEE 754. Кстати, преобразование Some<Bool> в Bool здесь по аналогии с работой WHERE в SQL: отбираются только строки со значением условия TRUE, а FALSE и NULL — нет, происходит эдакое впихивание трёх значенийOption<Bool> в два значения Bool.

          Я думаю о том, что блок с условием NaN == NaN не выполнится, по аналогии с тем, как в SQL не отберутся строки с NULL = NULL.

          Кстати, читал тут документацию, и почему-то NaN ** 0 в Javascript даёт единицу. Вероятно, потому что нулевая степень чего угодно, даже жаренной с грибами картошки, даёт всё равно единицу.


          1. 0xd34df00d
            28.10.2022 23:11
            +2

            Я человек простой: вижу монады — думаю о хаскеле. И вот в хаскеле есть инстанс Eq a => Eq (Maybe a), который определён как


            Nothing == Nothing = True
            Just a == Just b = a == b
            _ == _ = False

            — то есть, это как раз описанный мной вариант (и я недостаточно знаю SQL, чтобы согласиться или возразить на тему интерпретации через SQL).


            Чтобы выразить ваше отношение, я в хаскеле бы написал liftA2 (==), и это даёт ровно ваш вариант:


            λ> (~==~) = liftA2 (==)   -- я тут просто определил оператор
            λ> Nothing ~==~ Nothing
            Nothing
            λ> Nothing ~==~ Just 42
            Nothing
            λ> Just 42 ~==~ Just 42
            Just True
            λ> Just 42 ~==~ Just 43
            Just False

            Какое из этих отношений лучше и должно быть «по умолчанию» — вопрос из первых принципов, похоже, не решаемый, но по субъективному опыту == удобнее и нужно чаще. Ну, то есть, я сходу не могу придумать, когда бы мне на практике нужно было именно ~==~, а не ==.


            1. m03r
              29.10.2022 00:40

              ~==~ выглядит ровно как сравнение в SQL с учётом nullable, на этот счёт недавно была полемическая статья.

              Я совершенно согласен, что в целом для Maybe обыкновенный == выглядит удобнее почти во всех случаях.

              Кстати, в IEEE 754 прочитал (раздел 5.11):

              Four mutually exclusive relations are possible: less than, equal, greater than, and unordered. The last case arises when at least one operand is NaN. Every NaN shall compare unordered with everything, including itself.

              Соответственно, для каждой операции сравнения разный набор этих вариантов превращается в true или false, там ниже таблицы истинности.

              Как это работает — понятно, а что это с точки зрения типов и как это думать — перестал понимать совсем.


  1. erty3
    28.10.2022 10:46
    +1

    Решение проблемы — запрет на прямое сравнение с NaN
    Теперь разработчики видят ошибку

    Но ведь к NaN, как было написано, может приводить любая операция и процедура. Каким образом статический анализатор это поймёт? (прошу прощения если вопрос глупый, не спец по JS)


    1. Cubango
      28.10.2022 10:56
      +1

      речь идёт про TypeScript, тоесть отлавливаться данная проверка будет на этапе транспиляции


    1. fransua
      28.10.2022 18:32

      я думаю, он не все словит, скорей всего даже if (1/0 === 2/0) пропустит


      1. m03r
        29.10.2022 10:01
        +1

        1/0 === 2/0, потому что оба равны +Infinity


    1. iliazeus
      29.10.2022 12:16
      +1

      Это, во многом, нацелено именно на сравнение с константой NaN. Потому что на автомате часто пишут x != NaN для проверки на NaN, но эта проверка по правилам IEEE754 всегда истинна. Теперь случаи такой проверки будут подсвечены как ошибки.