Краткий обзор нововведений TypeScript 4.1

Я работаю с TypeScript уже не первый год и считаю, что он достаточно прост, особенно для людей с опытом разработки на Java. Тем не менее, прочитав новости о последнем крупном обновлении TypeScript 4.1, я сильно удивилась тому, как много я еще о нем не знаю.

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

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

Новые возможности

Шаблонные литералы

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

const message = `text`;

Как пояснил Флавио Коупс, строковые литералы предоставляют множество возможностей, которых не имеют стандартные строки:

  • улучшенный синтаксис для определения многострочных строк;
  • легкий способ интерполяции переменных и выражений в строки;
  • создание DSL (предметно-ориентированных языков) с помощью тегов шаблонов.

Синтаксис типов шаблонных литералов такой же, как и у строковых литералов в JS, только задействуются они в позиции типа:

type Entity = 'Invoice';

type Notification = `${Entity} saved`;
// эквивалент
// type Notification = 'Invoice saved';


type Viewport = 'md' | 'xs';
type Device = 'mobile' | 'desktop';

type Screen = `${Viewport | Device} screen`;
// эквивалент
// type Screen = 'md screen' | 'xs screen' | 'mobile screen' | 'desktop screen';

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

Пересопоставление ключей в отображаемых типах

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

Отображенные типы TypeScript:

type Actions = { [K in 'showEdit' | 'showCopy' | 'showDelete']?: boolean; };
// эквивалент
type Actions = {
  showEdit?:   boolean,
  showCopy?:   boolean,
  showDelete?: boolean
};

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

Пересопоставление ключей:

type MappedTypeWithNewKeys<T> = {
    [K in keyof T as NewKeyType]: T[K]
}

Новый спецификатор as позволяет задействовать такие возможности, как типы строковых литералов, позволяя легко создавать новые имена свойств на основе старых. Ключи можно отфильтровать при помощи never, что в ряде случаев избавляет от необходимости использовать вспомогательную инструкцию Omit.

Применение типов строковых литералов со спецификатором as :

type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;
//   ^ = type LazyPerson = {
//       getName: () => string;
//       getAge: () => number;
//       getLocation: () => string;
//   }

// Удаляем свойство 'kind'
type RemoveKindField<T> = {
    [K in keyof T as Exclude<K, "kind">]: T[K]
};

interface Circle {
    kind: "circle";
    radius: number;
}

type KindlessCircle = RemoveKindField<Circle>;
//   ^ = type KindlessCircle = {
//       radius: number;
//   }

Фабрики JSX 

JSX означает JavaScript XML. Он позволяет писать HTML-элементы в JS и помещать их в DOM без участия методов createElement() и/или appendChild()

Пример фабричных функций React JSX:

const greeting = <h4>Yes I can do it!</h4>;
ReactDOM.render(greeting, document.getElementById('root'));

TS 4.1 поддерживает фабричные функции jsx и jsxs из React 17 с помощью двух новых опций параметра компилятора jsx:

  • react-jsx
  • react-jsxdev

“Эти опции предназначены для продакшен-компиляции и компиляции в среде разработке, соответственно. При этом часто бывает, что одна расширяет другую” — из заметок TypeScript.

Вот два примера из документации, описывающие конфигурацию для продакшена и разработки:

tsconfig.json: пример конфигурации TS для продакшен-сборок:

// ./src/tsconfig.json
{
  "compilerOptions": {
    "module": "esnext",
    "target": "es2015",
    "jsx": "react-jsx",
    "strict": true
  },
  "include": ["./**/*"]
}

tsconfig.dev.json: пример конфигурации TS для сборок среды разработки:

// ./src/tsconfig.dev.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "jsx": "react-jsxdev"
  }
}
TS 4.1 предоставляет богатую проверку типов в JSX-средах, наподобие React. (источник)

Рекурсивные условные типы

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

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

/// Аналогичен `promise.then(...)`, но более точен в типах.
declare function customThen<T, U>(
    p: Promise<T>,
    onFulfilled: (value: Awaited<T>) => U
): Promise<Awaited<U>>;

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

Проверка индексных обращений

Сигнатуры индексов в TypeScriptпозволяют обращаться к свойствам с произвольным именем, как показано в интерфейсе Options ниже. Здесь мы видим, что свойство, в котором не указан path или permissions, должно иметь тип string | number.

Проверка индексных обращений (источник): 

interface Options {
  path: string;
  permissions: number;

  // Эта сигнатура индекса получает дополнительные свойства.
  [propName: string]: string | number;
}

function checkOptions(opts: Options) {
  opts.path; // string
  opts.permissions; // number

  // Все эти инструкции тоже допустимы
  // и имеют тип 'string | number'.
  opts.yadda.toString();
  opts["foo bar baz"].toString();
  opts[Math.random()].toString();
}

Новый флаг --noUncheckedIndexedAccess активирует режим, в котором любое обращение к свойству (например, opts.path) или индексное обращение (например, opts["blabla"]) трактуется как потенциально undefined. Это означает, что если вам нужно, как в данном примере, обратиться к свойству opts.path, то сначала необходимо выполнить проверку его наличия или использовать оператор ненулевого утверждения (постфикс !).

Применение флага --noUncheckedIndexedAccess (источник):

function checkOptions(opts: Options) {
  opts.path; // string
  opts.permissions; // number

  // При использовании noUncheckedIndexedAccess следующие действия   //недопустимы
  opts.yadda.toString();
Object is possibly 'undefined'.
  opts["foo bar baz"].toString();
Object is possibly 'undefined'.
  opts[Math.random()].toString();
Object is possibly 'undefined'.

  // Проверка присутствия свойства
  if (opts.yadda) {
    console.log(opts.yadda.toString());
  }

  // Утверждение в стиле "Я знаю, что делаю"
  // с помощью оператора '!'
  opts.yadda!.toString();
}

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

Paths без baseUrl

До выхода TS 4.1 для использования paths в файле tsconfig.json приходилось объявлять параметр baseUrl. В новом же релизе допускается определение paths без этого параметра. Таким образом решается проблема неправильных путей при автоматических импортах.

Пути и baseUrl в tsconfig.json:

{
    "compilerOptions": {
        "baseUrl": "./src",
        "paths": {
            "@shared": ["@shared/"] // Это сопоставление относится к //"baseUrl"
        }
    }
}

checkJs теперь подразумевает allowJs

Если у вас есть JS-проект, где вы используете опцию cjeckJs для регистрации ошибок в файлах .js, то вы также должны были объявить allowJs, чтобы JavaScript мог эти файлы компилировать. В TS 4.1 такой необходимости больше нет, поскольку теперь checkJs подразумевает allowJs по умолчанию.

checkJs и allowJs в tsconfig.json:

{
    "compilerOptions": {
      "allowJs": true,
      "checkJs": true
    }
}

Поддержка редакторами JSDoc-тега @see

Теперь при работе с TS в редакторах реализована улучшенная поддержка тега @see.

Применение тега @see (источник):

// @filename: first.ts
export class C {}

// @filename: main.ts
import * as first from "./first";

/**
 * @see first.C
 */
function related() {}

Кардинальные изменения

Изменения lib.d.ts 

lib.d.ts — это файл, создаваемый при каждой установке TypeScript. Он содержит внешние объявления для разных стандартных JS-конструкций, присутствующих в среде выполнения JavaScript и DOM, упрощая написание JS-кода с проверкой типов.

Этот файл автоматически включается в контекст компиляции проекта TS. Его можно исключить, указав в tsconfig.json флаг компилятора --nolib или "noLib": true

В связи со способом автоматической генерации типов DOM в TS 4.1, в lib.d.ts изменился ряд API. Например, Reflect.enumerate был удален, в том числе из ES2016.

"abstract" членов нельзя отмечать как «async"

Теперь членов, отмеченных как abstract, нельзя отмечать как async. Так что для коррекции кода вам придется удалить ключевое слово async.

Абстрактный класс TypeScript:

abstract class MyClass {
  abstract async create(): Promise<string>;
}

any/unknown распространяются на ложные позиции

До версии 4.1 в выражениях, наподобие foo && somethingElse, типом foo был либо any либо unknown. Типом всего выражения был бы тип somethingElse, который в следующем примере определен как { someProp: string }:

declare let foo: unknown;
declare let somethingElse: { someProp: string };let x = foo && somethingElse;

В TS 4.1 any и unknown распространяются не на тип в правой части, а во вне. Обычно исправить это можно сменой foo && someExpression на !!foo && someExpression.

Внимание: двойной восклицательный знак (!!) является сокращенным способом приведения переменной к логическому значению (true или false).

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

new Promise((resolve) => {
  doSomethingAsync(() => {
    doSomething();
    resolve();
  });
});

Предыдущий пример выбросит такую ошибку:

resolve()
  ~~~~~~~~~
error TS2554: Expected 1 arguments, but got 0.
  An argument for 'value' was not provided.

Чтобы это исправить, в resolve промиса необходимо передать не менее одного значения, либо объявить Promise с явным аргументом общего типа void в случаях, когда вызов resolve() требуется произвести без аргумента.

Промис с общим аргументом void:

new Promise<void>((resolve) => {
  doSomethingAsync(() => {
    doSomething();
    resolve();
  });
});

Условные операторы распространения создают необязательные свойства

В JavaScript распространения объектов { ...files} не работают для ложных значений. Это подразумевает пропуск files в случае, когда он null или undefined.

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

function getOwner(file?: File) {
  return {
    ...file?.owner,
    defaultUserId: 123,
  };
}

До выхода TypeScript 4.1, getOwner на основе каждого распространения возвращал тип-объединение:

{ x: number } | { x: number, name: string, age: number, location: string }
  • Если file определен, то передаются все свойства из Person (тип owner).
  • В противном случае ни одно.

Тем не менее это ведет к серьезному расходу ресурсов и обычно преимуществ не дает. Например, при сотнях распространений в одном объекте, каждое из которых потенциально добавляет сотни или тысячи свойств. Поэтому для повышения производительности в TypeScript 4.1 возвращаемый тип иногда использует все свойства в необязательной форме:

{
    x:         number;
    name?:     string;
    age?:      number;
    location?: string;
}

Несопоставимые параметры больше не связываются

Параметры, которые не соответствуют друг другу, ранее соотносились друг с другом через тип any.

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

Перегрузки JavaScript (источник):

let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x: { suit: string; card: number }[]): number;
function pickCard(x: number): { suit: string; card: number };
function pickCard(x: any): any {
  // Проверка, работаем ли мы с объектом/массивом.
  // Если да, то получаем колоду и выбираем карту
  if (typeof x == "object") {
    let pickedCard = Math.floor(Math.random() * x.length);
    return pickedCard;
  }
  // В противном случае выбор карты делает функция
  else if (typeof x == "number") {
    let pickedSuit = Math.floor(x / 13);
    return { suit: suits[pickedSuit], card: x % 13 };
  }
}

let myDeck = [
  { suit: "diamonds", card: 2 },
  { suit: "spades", card: 10 },
  { suit: "hearts", card: 4 },
];

let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: " + pickedCard1.card + " of " + pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert("card: " + pickedCard2.card + " of " + pickedCard2.suit);

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

Заключение

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

TypeScript 4.1 доступен через NuGet или NPM:

npm install typescript

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Rakia Ben Sassi: What Are Template Literal Types in TypeScript 4.1?