В середине марта 2023 года Майкрософт анонсировала релиз TypeScript версии 5.0. Разработчики ожидают от нее 10-20% прироста производительности, но так как всё зависит от кодовой базы и характеристик оборудования, они настоятельно рекомендуют опробовать эти изменения.

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

Что нового в TypeScript 4.9

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

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

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

type FormFields = "name" | "surname" | "age";

const data: Record<FormFields, number | string> = {
    name: "name",
    surname: "surname",
    age: 21,
}

const newAge = data.age * 2;
const nameUpperCase = data.name.toUpperCase();

Объект data имеет как числовые, так и строковые значения, поэтому при работе с этим объектом мы получаем следующие ошибки:

The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type. 


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

Вот тут-то нам и поможет оператор satisfies.

type FormFields = "name" | "surname" | "age";

const data = {
    name: "name",
    surname: "surname",
    age: 21,
} satisfies Record<FormFields, string | number>

const newAge = data.age * 2;
const nameUpperCase = data.name.toUpperCase();

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

type FormFields = "name" | "surname" | "age";


const data = {
    name: "name",
    surname: "surname",
    age: 21,
    passport: {}
} satisfies Record<FormFields, string | number>

Меньше ошибок при сужении типов в операторе in

Оператор in в JavaScript — помогает понять нам, существует ли свойство у объекта.

В TypeScript он помогает отделить один тип от другого.

type AutocompleteDefaultOption = {
  data: unknown,
  value: string
}

type AutocompleteCustomOption = {
  inputValue: string,
  data: unknown
}

type AutocompleteOption = AutocompleteCustomOption | AutocompleteDefaultOption

const getOption = (option: AutocompleteOption) => {
  if("inputValue" in option) {
    option // В этом блоке имеет тип AutocompleteCustomOption
  }
}

Но с этим оператором возникала следующая ошибка:

Предположим, что есть объект, который приходит с сервера.

type ServerResponse = unknown

const response: ServerResponse = {
  name: "name",
  surname: "surname",
}

if(response && typeof response === 'object' && 'name' in response) {
  const name = response.name
}

При работе с ним мы проверяем наличие свойства, которое нас интересует, и если это свойство существует, то выполняем инструкции, находящиеся в блоке if. В старых версиях TypeScript приводит response к типу object, и показывает такую ошибку:

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

Хотя выше проверка на наличие этого свойства прошла успешно. Мы можем конвертировать переменную response, допустим, к типу any: 

if(response && typeof response === 'object' && 'name' in response) {
  const name = (response as any).name
}

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

К нам на выручку приходит TypeScript версии 4.9. При проверке наличия свойства через оператор in, правому операнду TypeScript сужает тип до:

object & Record<"название свойства", unknown>

Благодаря этой возможности мы можем использовать эту конструкцию без конвертации объекта response.

if(response && typeof response === 'object' && 'name' in response) {
  const name = response.name // name: unknown
}

Использование auto-accessor в классах

В TypeScript 4.9 появилась поддержка новой функции auto-accessor из ECMAScript.

Ключевое слово accessor — это синтаксический сахар для создания get и set методов приватного свойства.

До появления accessor поля класса с помощью декораторов пытались превратить в свойства путём добавления get/set методов. Auto-accessor решает эту проблему.

Auto-accessor имеет несколько возможностей:

  • Позволяет подклассам переопределять get/set без поля суперкласса.

  • При применении декоратора к такому свойству, он получает доступ к get/set методам и может дополнять их без изменения структуры класса.

Пример нового декоратора из TypeScript 5.0 для accessor поля:

function Logger<This, Value>(
  { get, set }: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext
): ClassAccessorDecoratorResult<This, Value> | void {
  const { kind, name } = context;
  const fieldName = String(name);
  if (kind === "accessor") {
    return {
      get(this): Value {
        console.log(`Logger: get ${fieldName}`);
        return get.call(this);
      },


      set(this, val: Value) {
        console.log(`Logger: set ${fieldName} to ${val}`);
        return set.call(this, val);
      },
    };
  }
}


class Human {
  @Logger
  accessor name: string


  constructor(name: string) {
    this.name = name
  }


  greet() {
    console.log(`Hello, i am ${this.name}`)
  }
}


const human = new Human('Ivan') // Logger: set name to Ivan
human.greet() // Logger: get name

Пример без accessor:

class A {
  #name: string = ''

  get name() {
    return this.#name
  }

  set name(value: string) {
    this.#name = value
  }

  constructor(name: string) {
    this.name = name
  }
}

Пример с accessor:

class B {
  accessor name: string

  constructor(name: string) {
    this.name = name
  }
}

Проверки на равенство с NaN

NaN — специальное числовое значение, расшифровывается как «Not A Number». Результатом сравнения числового значения или же самого NaN с NaN будет false.

console.log(0 === NaN)         // false
console.log(0 == NaN)         // false
console.log(NaN === NaN)     // false
console.log(NaN == NaN)     // false

Лучшее решение проверить, является ли значение NaN — использовать статический метод isNaN класса Number.

console.log(Number.isNaN(0))      // false
console.log(Number.isNaN(NaN))   // true

В предыдущих версиях TypeScript не обращал внимание на прямое сравнение с NaN. В версии 4.9 при прямом сравнении значения с NaN TypeScript выбрасывает ошибку:

This condition will always return 'false' 
Did you mean 'Number.isNaN(...)'?

Новые команды для редактирования кода

В предыдущих версиях TypeScript существовала команда «Organize Imports», которая удаляла неиспользуемые импорты, и переписывала файл, сортируя оставшиеся импорты.

В TypeScript 4.3 добавили команду только для сортировки импортов «Sort Imports». Её проблема заключалась в том, что она была доступна как команда при сохранении, и не запускалась вручную.

В версии 4.9 появилась команда «Remove unused imports», которая удаляет неиспользуемые импорты, не меняя их порядок.

Теперь все три команды доступны во всех редакторах кода.

Улучшение производительности

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

forEachChild — является основной функцией для обхода синтаксических узлов в компиляторе. Благодаря рефакторингу этой функции удалось ускорить этап связывания (binding) на 20%.

Вслед за успехом оптимизации функции forEachChild эти же приёмы опробовали на функции visitEachChild — она используется для преобразования узлов компилятора. Прирост производительности составил 3%.

Что нового в TypeScript 5.0

Функции-декораторы

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

Пример класса без декоратора:

class Person {
  age: number = 0

  changeAge() {
    console.log("Logger: Func start")
    console.log("changing age...")
    console.log("Logger: Func end")
  }
}

const person = new Person();
person.changeAge()

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

Пример декоратора:

function Logger<This, Args extends number[], Return>(
    target: (this: This, ...args: Args) => Return, 
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  return function(this: This, ...args: Args) {
    console.log("Logger: Func start")
    const result = target.call(this, ...args)
    console.log("Logger: Func end")

    return result
  }
}

Декоратор принимает функцию, к которой мы применяем этот декоратор и контекст.
И возвращает функцию, в которой мы можем добавить логирование, главное, не забыть вернуть результат выполнения функции target.

Теперь посмотрим, как применить декоратор к нашему классу: 

class Person {
  age: number = 0

  @Logger
  changeAge() {
    console.log("changing age...")
  }
}

const person = new Person();
person.changeAge()

Функции-декораторы можно объединять в цепочки. Например, представим, что нам необходимо добавить валидацию для метода changeAge. Делать проверку непосредственно внутри метода не будет являться хорошим тоном, так как если нам понадобится добавить такую же валидацию в другой метод, мы будем нарушать принцип DRY. Правильным решением в данном случае будет воспользоваться декоратором.

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

function Min<This, Args extends number[], Return>(minValue: number) {
  return function(
    target: (this: This, ...args: Args) => Return, 
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
  ) {
    return function(this: This, ...args: Args) {
      if(args[0] < minValue) {
        throw new Error(`Возраст меньше ${minValue}`)
      }

      return target.call(this, ...args)
    }
  }
}

Теперь воспользуемся этим декоратором: 

class Person {
  age: number = 0

  @Logger
  @Min(18)
  changeAge(value: number) {
    this.age = value
  }
}

const person = new Person();
person.changeAge(10)  // Error: Возраст меньше 18

Мы смогли добавить валидацию на метод changeAge, при этом сохранили логирование этого метода.

Декораторы можно использовать не только для методов класса, «декорировать» можно также свойства класса, геттеры и сеттеры, auto-accessor, а также и сами классы.

Const для типов параметров функций

В TypeScript 5.0 добавлена возможность работать с типом, который передаём в дженерик, как с литералом.

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

Создадим функцию, в которую будем передавать массив пользователей.

const parseUsers = <T extends {name: string, place: string}, >(users: T[]) => {
  const getUserByName = (name: T['name']) => users.find((user) => user.name === name)
  return getUserByName
}

Функция возвращает другую функцию, при вызове которой мы получим пользователя, чьё имя мы в неё передали.

const getUser = parseUsers([{
  name: "Ilya",
  place: "Krasnodar"
}, {
  name: "Dmitry",
  place: "Moscow"
}, {
  name: "Pavel",
  place: "Saint Petersburg"
}])

const currentUser = getUser("Sergey") 

И переменная currentUser будет равна undefined, так как мы передали имя пользователя, которого нет в массиве users.

Чтобы избежать этой проблемы, в TypeScript 5.0 добавили возможность использовать const нотацию в generic. Чтобы её использовать, достаточно добавить const в generic функции parseUsers.

const parseUsers = <const T extends {name: string, place: string}, >(users: T[]) => {
  const getUserByName = (name: T['name']) => users.find((user) => user.name === name)
  return getUserByName
}

Теперь при вызове функции getUsers с именем пользователя, которого нет в массиве users, TypeScript выдаст нам ошибку:

const currentUser = getUser("Sergey") 

 Argument of type '"Sergey"' is not assignable to parameter of type '"Ilya" | "Dmitry" | "Pavel"

Улучшения в работе с Enum

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

enum LogLevel {
  Debug, // 0
  Log, // 1
  Warning, // 2
  Error // 3
}

Напишем функцию, которая будет принимать значения перечисления, и сообщение.

const showMessage = (logLevel: LogLevel, message: string) => {
  // code...
}

При работе с этой функцией первым параметром мы можем передать числовое значение, соответствующее значению перечисления.

showMessage(0, 'debug message')
showMessage(2, 'warning message')

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

showMessage(99, 'anything text…')

В TypeScript 5.0 этот кейс исправили, теперь при передаче значения которого нет в перечислении, появляется ошибка:

Argument of type '99' is not assignable to parameter of type 'LogLevel' 

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

showMessage(LogLevel.Debug, 'debug message')
showMessage(LogLevel.Warning, 'warning message')

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

Также в TypeScript 5.0 все перечисления теперь рассматриваются как объединённые перечисления, удалось этого достичь путём создания уникального типа для каждого вычисляемого значения перечисления.

Рассмотрим на примере версии до 5.0:

enum FieldName {
  MonthIncome = "monthIncome",
  AdditionalMonthIncome = `additional-${FieldName.MonthIncome}`
}

Получим ошибку:

Computed values are not permitted in an enum with string valued members

В TypeScript 5.0 всё работает без ошибок.

Поддержка нескольких конфигурационных файлов

В TypeScript существует возможность подключать сторонний конфигурационный файл, указав путь до него в поле extends:

{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
        "outDir": "../dist",
        
  }
}

Раньше можно было указать путь только для одного файла. В версии 5.0 для большей гибкости в настройке TypeScript добавили возможность указывать путь до нескольких файлов:

{
  "extends": ["./tsconfig1.json", "./tsconfig2.json"],
  "compilerOptions": {
        "outDir": "../dist",
        
  }
}

Оптимизация TypeScript

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

Вот некоторые улучшения в скорости и размере, их удалось достичь в сравнении с TypeScript 4.9:

Источник: Announcing TypeScript 5.0 Beta 
Источник: Announcing TypeScript 5.0 Beta 

Одним из главных изменений стал перевод TypeScript с пространства имён на модули, это позволяет использовать современные инструменты сборки. Применение этого инструментария, удаление неиспользуемого кода, пересмотр стратегии сборки позволили сократить размер бандла на 26,5 МБ от общего размера в 63,8 МБ, и также заметно ускорить работу TypeScript.

Заключение

Резюмирую изменения, которые произошли с TypeScript в версии 4.9 и 5.0. Мне показалось, что изменений в TypeScript 4.9 было немного, но они значимы: 

  • новый оператор — satisfies

  • улучшения при сужении типов в операторе in

Версия 5.0, наоборот, очень богата на изменения и добавления новых, удобных возможностей: 

  • функции — декораторы

  • возможность подключить несколько конфигурационных файлов 

  • улучшения в работе с enum

  • множество изменений в оптимизации работы TypeScript

Эти версии не похожи друг на друга, но объединяет их то, что они делают нашу жизнь чуточку лучше, а работу — немного приятнее. 

Спасибо за внимание!

Авторские материалы для frontend-разработчиков мы также публикуем в наших соцсетях – ВКонтакте и Telegram.

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


  1. mayorovp
    07.06.2023 08:41
    +3

    Что-то про auto-accessor как-то вообще коротко написали, непонятно зачем он нужен. В других языках автосвойства нужны потому, что в будущем может понадобиться полноценное свойство, а замена поля на свойство нарушает ABI. Но в JavaScript-то такой проблемы нет!


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


    class Foo {
        @observable bar = "baz";
    }

    "За" были авторы реактивных библиотек, "против" — разработчики браузеров. В итоге стороны сошлись на введении в язык auto-accessor.


    1. LordDarklight
      07.06.2023 08:41

      а можно подробнее что за ABI и там нарушает там замена полей на свойства. TypeScript же транслятор в JavaScript - то как это выглядит в TypeScipt это одно - то, во что это транслируется в JavaScript - совсем другое


      1. mayorovp
        07.06.2023 08:41

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

        Другие языки — это C#, Scala, Kotlin


        1. LordDarklight
          07.06.2023 08:41

          В каких языках это есть я знаю. Не знаю про нарушение ABI - о чём и спросил. И чем TypeScript тут принципиально отличается от, скажем, Kotlin - оба в JS транслироваться могут! Ну а в перспективе - вообще в WASM


          1. mayorovp
            07.06.2023 08:41
            +2

            Как я и писал изначально, в JavaScript никакой разницы в ABI нет. Собственно, в JavaScript и полей-то в привычном понимании нет.


            А вот как раз в WASM разница между полем и свойством ещё как будет:


            Поле — это именованная область памяти внутри объекта. Чтение или запись в поле — это операция косвенного обращения к памяти.


            Свойство — это пара методов. Чтение или запись свойства — это вызов функции.


            Если у вас раньше в классе было поле, и вы решили заменить его на свойство — то любой зависимый код должен быть перекомпилирован, иначе он будет делать совершенно некорректную операцию. Это и есть нарушение ABI.


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


            1. LordDarklight
              07.06.2023 08:41

              Спасибо за пояснения


    1. SSul
      07.06.2023 08:41

      Спасибо за интерес и справедливое замечание. Расширили раздел про auto-accessor


  1. flancer
    07.06.2023 08:41

    Разработчики ожидают от нее 10-20% прироста производительности

    TS это же транспилятор, значит разработчики добились прироста производительности при преобразовании TS-кода в JS-код? Ну, тоже хорошо.