release

3.8

Импорт и экспорт только типа и флаг --importsNotUsedAsValues

Механизм уточнения импорта и экспорта (import\export) выступает в качестве указаний компилятору, что данную конструкцию следует воспринимать исключительно как тип. Форма уточняющего импорта и экспорта включает в себя ключевое слово type идущее следом за ключевым словом import либо export.

ts
import type {Type} from "./type";
export type {Type};

Уточнению могут подвергаться только конструкции расцениваемые исключительно как типы (interface, type alias и class).

ts
// @file types.ts

export class ClassType {}
export interface IInterfaceType{}
export type AliasType = {};
ts
// @file index.ts

import type {ClassType, IInterfaceType, AliasType} from "./types";
export type {ClassType, IInterfaceType, AliasType};

Значения к которым можно отнести как экземпляры объектов, так и функции (function expression и function declaration) уточнятся, как в отдельности так и в одной форме с типами, не могут.

ts
// @file types.ts

export class ClassType {}
export interface IInterfaceType{}
export type AliasType = {};


export const o = {};

export const fe = ()=>{};
export function fd(){}
ts
// @file index.ts

// import type {o, fe, fd} from "./types"; // Error! Type-only import must reference a type, but 'o' is a value.ts(1361)
// import type {o, fe, fd, ClassType, IInterfaceType, AliasType} from "./types"; // Error! Type-only import must reference a type, but 'o' is a value.ts(1361)
import {o, fe, fd} from "./types"; // Ok!


// export type {o, fe, fd}; // Error! Type-only export must reference a type, but 'o' is a value.ts(1361)
// export type {o, fe, fd, ClassType, IInterfaceType, AliasType} from "./types"; // Error! Type-only export must reference a type, but 'o' is a value.ts(1361)
export {o, fe, fd}; // Ok!

Кроме того, уточнённая форма импорта и экспорта не может одновременно содержать импорт\экспорт по умолчанию и не по умолчанию.

ts
// @file types.ts

export default class DefaultExportType {}
export class ExportType {}
ts
// @file index.ts

/**
 * Error!
 * All imports in import declaration are unused.ts(6192)
 * A type-only import can specify a default import or named bindings, but not both.ts(1363)
 */
import type DefaultType, {ExportType} from "./types";

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

ts
// @file Base.ts

export class Base {}
ts
// @file index.ts

import type {Base} from "./Base";

class Derived extends Base{}; // 'Base' only refers to a type, but is being used as a value here.ts(2693)

В дополнение механизму уточнения формы импорта\экспорта был добавлен флаг --importsNotUsedAsValues ожидаемый одно из трех значений. Но прежде чем познакомится с каждым предлагаю поглубже погрузится в природу возникновения необходимости в данном механизме.

Большинство разработчиков используя в повседневной работе механизм импорта\экспорта даже не подозревают, что с ним связанно немало различных трудностей, которые возникают из-за механизмов призванных оптимизировать код. Но для начала рассмотрим несколько простых вводных примеров.

Представьте ситуацию при которой один модуль импортирует необходимый ему тип представленный такой конструкцией как interface.

ts
// @file IPerson.ts

export interface IPerson {
    name: string;
}
ts
// @file action.ts

import {IPerson} from "./IPerson";

function action(person:IPerson){
    // ...
}

Поскольку интерфейс является конструкцией присущей исключительно TypeScript, то не удивительно, что после компиляции от неё не останется и следа.

js
// после компиляции @file action.js

function action(person){
    // ...
}

Теперь представьте, что один модуль импортирует конструкцию представленную классом, который задействован в логике уже знакомой нам функции action().

ts
// @file IPerson.ts

export interface IPerson {
    name: string;
}

export class Person {
    constructor(readonly name:string){}

    toString(){
        return `[person ${this.name}]`;
    }
}
ts
// @file action.ts

import {IPerson} from "./IPerson";
import {Person} from "./Person";


function action(person:IPerson){
    new Person(person);
}
js
// после компиляции @file action.js

import {Person} from "./Person";


function action(person){
    new Person(person);
}

В этом случае класс Person был включён в скомпилированный файл поскольку необходим для правильного выполнения программы.

А теперь представьте ситуацию когда класс Person задействован в том же модуле action.ts, но исключительно в качестве типа. Другими словами он не задействован в логике работы модуля.

ts
// @file Person.ts

export class Person {
    constructor(readonly name:string){}

    toString(){
        return `[person ${this.name}]`;
    }
}
ts
// @file action.ts

import {Person} from "./Person";


function action(person:Person){
    //...
}

Подумайте, что должна включать в себя итоговая сборка? Если вы выбрали вариант идентичный первому, то вы совершенно правы! Поскольку класс Person используется в качестве типа, то нет смысла включать его в результирующий файл.

js
// после компиляции @file action.js


function action(person){
    //...
}

Подобное поведение кажется логичным и возможно благодаря механизму называемому import elision. Этот механизм определяет, что конструкции которые теоретически могут быть включены в скомпилированный модуль требуются ему исключительно в качестве типа. И как уже можно было догадаться именно с этим механизмом и связанны моменты мешающие оптимизаций кода. Тут-то и вступает в дело механизм уточнения формы импорта\экспорта.

Механизм уточнения способен разрешить возникающие перед import-elision трудности при ре-экспорте модулей предотвращению которых способствует установленный в значение true флаг --isolatedModules.

ts
// @file module.ts
export interface IActionParams{}
export function action(params:IActionParams){}
ts
// @file re-export.ts

import {IActionParams, action} from "./module";

/**
 * [Error! ts <3.8] > Cannot re-export a type when the '--isolatedModules' flag is provided.ts(1205)
 * 
 * [Error! ts >=3.8] > Re-exporting a type when the '--isolatedModules' flag is provided requires using 'export type'.ts(1205)
 */
export {IActionParams, action};


/**
 * 
 * Поскольку компиляторы как TypeScript так и Babel
 * в контексте файла неспособны определить является
 * ли конструкция IActionParams допустимой для JavaScript
 * существует вероятность возникновения ошибки. Простыми
 * словами механизмы обоих компиляторов не знаю нужно ли
 * удалять следы связанные с IActionParams из скомпилированного
 * .js кода или нет. Именно поэтому был добавлен флаг 
 * --isolatedModules который предупреждает о опасной ситуации.
 */

 

Рассмотренный выше случай можно разрешить с помощью явного уточнения формы импорта\экспорта.

ts
// @file re-export.ts

import {IActionParams, action} from "./module";

/**
 * Явно указываем, что IActionParams это тип.
 */
export type {IActionParams};
export {action};

Специально введенный и ранее упомянутый флаг --importsNotUsedAsValues, как уже было сказано, ожидает одно из трех возможных на данный момент значений - remove, preserve или error.

Значение remove активирует или другими словами оставляет поведение реализуемое до версии 3.8. Значения preserve способно разрешить проблему возникающую при экспорте так называемых сайд-эффектов.

ts
// @file module-with-side-effects.ts

function incrementVisitCounterLocalStorage(){
    // увеличиваем счетчик посещаемости в localStorage
}

export interface IDataFromModuleWithSideEffects{};

incrementVisitCounterLocalStorage(); // ожидается, что вызов произойдет в момент подключения модуля
ts
// @file index.ts

import {IDataFromModuleWithSideEffects} from "./module";

let data:IDataFromModuleWithSideEffects = {};

/**
 * Несмотря на то, что модуль module.ts
 * задействован в коде, его содержимое
 * не будет включено в скомпилированную
 * программу, поскольку компилятор исключает
 * импорты конструкций не участвующих в её логике.
 * Таким образом функция incrementVisitCounterLocalStorage()
 * никогда не будет вызвана, а значит программа не будет
 * работать корректно! 
 */
js
// после компиляции @file index.js

let data = {};

/**
 * В итоге программе ничего не
 * известно о модуле module-with-side-effects.ts
 */

Решение из ситуации описанной выше заключается в повторном указании импорта всего модуля. Но не всем такое решение кажется очевидным.

ts
import {IDataFromModuleWithSideEffects} from "./module-with-side-effects";
import "./module-with-side-effects"; // импорт всего модуля

let data:IDataFromModuleWithSideEffects = {};
js
// после компиляции @file index.js

import "./module-with-side-effects.js";

let data = {};

/**
 * Теперь программа выполнится так как и ожидалось.
 * То есть модуль module-with-side-effects.ts включен
 * в её состав.
 */

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

ts
import {IDataFromModuleWithSideEffects} from "./module-with-side-effects"; //This import may be converted to a type-only import.ts(1372)

Кроме того, флаг preserve в отсутствие уточнения поможет избавиться от повторного указания импорта. Простыми словами значение preserve указывает компилятору импортировать все модули полностью.

ts
// @file module-with-side-effects.ts

function incrementVisitCounterLocalStorage(){
    // увеличиваем счетчик посещаемости в localStorage
}

export interface IDataFromModuleWithSideEffects{};

incrementVisitCounterLocalStorage(); 
ts
// @file module-without-side-effects.ts

export interface IDataFromModuleWithoutSideEffects{};
ts
// @file index.ts


// Без уточнения
import {IDataFromModuleWithSideEffects} from "./module-with-side-effects";
import {IDataFromModuleWithoutSideEffects} from "./module-without-side-effects";


let dataFromModuleWithSideEffects:IDataFromModuleWithSideEffects = {};
let dataFromModuleWithoutSideEffects:IDataFromModuleWithoutSideEffects = {};
js
// после компиляции @file index.js

import "./module-with-side-effects";
import "./module-without-side-effects";

let dataFromModuleWithSideEffects = {};
let dataFromModuleWithoutSideEffects = {};

/**
 * 
 * Несмотря на то, что импортировались
 * исключительно конструкции-типы, модули
 * были импортированы полностью.
 */

В случае уточнения поведение при компиляции останется прежним. То есть в импорты в скомпилированный файл включены не будут.

ts
// @file index.ts


// С уточнением
import type {IDataFromModuleWithSideEffects} from "./module-with-side-effects";
import type {IDataFromModuleWithoutSideEffects} from "./module-without-side-effects";


let dataFromModuleWithSideEffects:IDataFromModuleWithSideEffects = {};
let dataFromModuleWithoutSideEffects:IDataFromModuleWithoutSideEffects = {};
js
// после компиляции @file index.js

let dataFromModuleWithSideEffects = {};
let dataFromModuleWithoutSideEffects = {};

/**
 * 
 * Импорты отсутствуют.
 */

Если же флагу --importsNotUsedAsValues задано значение error, то при импортировании типов без явного уточнения будет считаться ошибочным поведением.

ts
// @file index.ts

/**
 * 
 * [0][1] Error > This import is never used as a value and must use 'import type' because the 'importsNotUsedAsValues' is set to 'error'.ts(1371)
 */

import {IDataFromModuleWithSideEffects} from "./module-with-side-effects";
import {IDataFromModuleWithoutSideEffects} from "./module-without-side-effects";


let dataFromModuleWithSideEffects:IDataFromModuleWithSideEffects = {};
let dataFromModuleWithoutSideEffects:IDataFromModuleWithoutSideEffects = {};

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

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

Закрытые поля ECMAScript

Помимо сокрытия полей класса от внешней среды с помощью модификатора доступа private, присущего исключительно TypeScript, начиная с версии 3.8 появилась возможность прибегнуть к механизму предусмотренного спецификацией ECMAScript. Для того, что бы воспользоваться данным механизмом идентификаторы скрываемых полей должны начинаться с символа решетка #. Доступ к защищённому полю класса ограничивается областью видимости класса в котором оно объявлено, а при обращении к нему необходимо также указывать символ решетка.

ts
class Animal {
    #isLife:boolean = true; // защищенное поле класса

    get isLife(){
        return this.#isLife;
    }
}

let animal = new Animal();
console.log(animal.isLife); // обращение к аксессору, а не защищенному полю

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

ts
class Animal {
    #isLife:boolean = true; // защищенное поле класса
}
class Bird extends Animal {
    constructor(){
        super();
        this.#isLife; // Error! > Property '#isLife' is not accessible outside class 'Animal' because it has a private identifier.ts(18013)
    }
}

В отличии от модификатора доступа private данный механизм не может быть применен к методам класса, но так как за его появлением стоит спецификация ECMAScript, он продолжает действовать в скомпилированной программе. Именно поэтому, в отличии от сценария с модификатором доступа private, потомки могут без страха нарушить ожидаемый ход выполнения программы объявлять защищенные поля чьи идентификаторы идентичны объявлениям в их супер-классах.

ts
// сценарий с модификатором доступа private

class Animal {
    private _isLife:boolean = true;
    
}
/**
 * Error!
 * 
 * Class 'Bird' incorrectly extends base class 'Animal'.
  Types have separate declarations of a private property '_isLife'.ts(2415)
 */
class Bird extends Animal {
    private _isLife: boolean = false;

}
ts
// сценарий с защищенными полями предусмотренными спецификацией ECMAScript

class Animal {
    #isLife:boolean = true;
    
}
/**
 * Ok!
 */
class Bird extends Animal {
    #isLife: boolean = false;

}

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

ts
class Animal {
    // Parameter declaration expected.ts(1138)
    constructor(#isLife = true){}
    
}

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

await высшего уровня

Поскольку современную разработку на языке JavaScript сложно представить без таких конструкций как Promise, которые выглядят намного привлекательней при использовании совместно с таким механизмом активирующимся при помощи ключевого слова await. Но правилами установлено, что ключевое слово await должно указываться исключительно в функциях объявленных с использованием ключевого слова async. Это в свою очередь, в некоторых случаях вынуждало разработчиков объявлять не требующиеся им функции.

ts
/**
 * Применение ключевого слова await
 * требует объявления функции в которой
 * появляется потребность исключительно
 * из-за необходимости в ключевом слове async
 */

const run = async () => {
    let hello = await Promise.resolve(`Hello`);
    let world = await Promise.resolve(`World`);
    
    
    return `${hello} ${world}!`
};

run().then(greeting=> console.log(greeting));

Создатели спецификации ECMScript обратили на это внимание и добавили в неё такой механизм, как await высшего уровня (top-level await). await высшего уровня позволяет избавиться от не требующейся функции.

ts
// @file index.ts

/**
 * Внимание, псевдо код!
 * Данный код находящийся
 * в файле index.ts не считается
 * модулем. Объяснение дается далее
 * по содержанию. 
 */

let hello = await Promise.resolve(`Hello`);
let world = await Promise.resolve(`World`);

let greeting = `${hello} ${world}!`;

console.log(greeting);

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

ts
// @file greeting-utils.ts

export const toMessage = (hello:string,world:string) => 
    `${hello} ${world}!`;
ts
// пример с import

import * as GreetingUtils from "./greeting-utils";

let hello = await Promise.resolve(`Hello`);
let world = await Promise.resolve(`World`);

let greeting = GreetingUtils.toMessage(hello, world);

console.log(greeting);
ts
// пример с пустым экспортом

let hello = await Promise.resolve(`Hello`);
let world = await Promise.resolve(`World`);

let greeting = `${hello} ${world}!`;

console.log(greeting);

export {};

Кроме того, поддержка await высшего уровня становится доступной при компиляции в версию начиная с es2017, а в качестве модулей выбрано esnext или system.

Реализация новой формы ре-экспорта

Зачастую появляется необходимость ре-экспорта содержимого модуля, как единую точку входа.

ts
// @file utils.ts

export const sum = (a:number, b:number) => a + b;
export const mul = (a:number, b:number) => a * b;
ts
// MathUtils.ts

import * as MathUtils from "./utils";
export {MathUtils};

Подобное встречается столь часто, что в спецификацию ECMAScript 2020 была включена новая форма ре-экспорта всего содержимого.

ts
export * as Identificator from "path";

Благодаря разработчикам языка TypeScript такой вид ре-экспорта стал доступен начиная с версии 3.8. Предыдущий пример с применением нового синтаксиса мог бы сократится до одной строчки.

ts
// MathUtils.ts

export * as MathUtils from "./utils";

Новая конфигурационная группа параметров watchOptions

Оптимально организовать наблюдение за файловой системой дело довольно не простое поскольку ресурсоемкость и энергозатратность сильно зависит как от api платформы, так и от предоставляемых различными библиотеками деклараций. И это не удивительно, ведь ОС по-разному реализуют процесс наблюдения и тем самым затрудняют поддержание актуальности при интенсивном изменении отслеживаемых файлов. И, кроме того, только задумайтесь, сколько пакетов в директории nodemodules_ и каково суммарное количество строк предоставляемых ими деклараций .d.ts?

Поэтому перед разработчиками TypeScript всегда остро стояла задача максимально оптимизировать этот процесс. Как следствие в конфигурационном файле tsconfig.json появилась новая группа для конфигурирования watchOptions позволяющая выбрать оптимальную стратегию в зависимости от самого проекта.

json
// tsconfig.json

{
    "compilerOptions": {},
    // новое поле
    "watchOptions": {

    }
}

Так новая группа реализует четыре параметра - watchFile, watchDirectory, fallbackPolling и synchronousWatchDirectory.

json
// tsconfig.json

{
    "compilerOptions": {},
    // новое поле
    "watchOptions": {
        "watchFile": "...",
        "watchDirectory": "...",
        "fallbackPolling": "...",
        "synchronousWatchDirectory": "..."
    }
}
  • watchFile: стратегия наблюдения за отдельными файлами

    • fixedPollingInterval: Проверять каждый файл на наличие изменений несколько раз в секунду с фиксированным интервалом.
    • priorityPollingInterval: Проверять каждый файл на наличие изменений несколько раз в секунду, но использовать эвристику для проверки файлов определенных типов реже, чем других.
    • dynamicPriorityPolling: Использовать динамическую очередь, в которой менее часто изменяемые файлы будут проверяться реже.
    • useFsEvents [ПО УМОЛЧАНИЮ]: Пытаться использовать собственные события операционной системы / файловой системы для изменения файлов.
    • useFsEventsOnParentDirectory: Пытаться использовать собственные события операционной системы/файловой системы для прослушивания изменений в каталогах, содержащих файл. Это может использовать меньше файловых наблюдателей, но также быть менее точным.
  • watchDirectory: стратегия наблюдения за целыми деревьями каталогов в системах, в которых отсутствует рекурсивная функция наблюдения за файлами.

    • fixedPollingInterval: Проверять каждый каталог на наличие изменений несколько раз в секунду с фиксированным интервалом.
    • dynamicPriorityPolling: Использовать динамическую очередь, в которой менее часто изменяемые каталоги будут проверяться реже.
    • useFsEvents[ПО УМОЛЧАНИЮ]: Пытаться использовать собственные события операционной системы / файловой системы для изменений каталога.
  • fallbackPolling: при использовании событий файловой системы этот параметр определяет стратегию опроса, которая используется, когда в системе заканчиваются собственные наблюдатели файлов и / или не поддерживаются собственные средства просмотра файлов.

    • fixedPollingInterval: см выше.
    • priorityPollingInterval[ПО УМОЛЧАНИЮ]: см выше.
    • dynamicPriorityPolling: см выше.
  • synchronousWatchDirectory: Отключить отложенное наблюдение за каталогами.

    • true
    • false[ПО УМОЛЧАНИЮ]

Новый флаг --assumeChangesOnlyAffectDirectDependencies

С помощью таких опций компилятора как --watch и --incremental можно значительно сократить время сборки проекта. Напомню, что первый активирует наблюдение за файлами, а второй устанавливает связи между ними при помощи генерации файла с метаинформацией .tsbuildinfo.

Но на очень больших проектах этих мер по сокращению время сборки довольно недостаточно. Поэтому многие разработчики высказываются за сокращение время сборки в угоду точности проверок изменений. Итогом подобных рассуждений стал новый флаг компилятора --assumeChangesOnlyAffectDirectDependencies при активации которой компилятор не будет перепроверять\перестраивать файлы, которые на основе метаинформации считаются затронутыми. Вместо этого будут пepeпpoвepятьcя\перестраиваться только непосредственно изменённые файлы и файлы их импортирующие.

Представьте, что fileA.ts импортирует fileB.ts, который импортирует fileC.ts, который импортирует fileD.td.

При активном режиме --watch изменения в файле fileD.ts означает, что как минимум будут проверены fileC.ts, fileB.ts и fileA.ts. При активной опцией --assumeChangesOnlyAffectDirectDependencies проверке подвергнется лишь fileA.ts и fileB.ts.

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

Модификаторы JSDocs

Компиляция .js файлов доступная за флагом allowJs была бы не эффективной если бы отсутствовало аннотирование JavaScript кода при помощи JSDoc комментариев активируемых флагом checkJs или комментарной директивой // @ts-check. Чтобы повысить эффективность данного механизма были добавлены новые JSDoc директивы. Так компилятор TypeScript получил поддержку модифицирующих директив как @public, @private и @protected, чье поведение полностью соответствует поведению одноименных модификаторов из TypeScript. Кроме того, без изменений остался список членов класса к которым эти модификаторы могут быть применены (поля, свойства, методы как экземпляра, так и самого класса).

js
class Base {
    constructor(){
        /**@public */
        this.public = 0;

        /**@private */
        this.private = 0;

        /**@protected */
        this.protected = 0;
    }

    /**@private */
    method(){}
}

Помимо этого также был добавлен модификатор /** @readonly */, чьё поведение также полностью идентичное одноименному модификатору из TypeScript, который к тому же можно совмещать с другими модификаторами.

js
class Base {
    /**@readonly */
    static READONLY = true;

    /**@protected @readonly */
    static PROTECTED_READONLY = true;
}

[КРИТИЧЕСКОЕ ИЗМЕНЕНИЕ] Более строгие проверки объединений имеющих с индексной сигнатурой

До текущей версии типу объединения включающего тип с индексной сигнатурой было возможно присвоить значение имеющее поля неудовлетворяющие этой индексной сигнатуре. Начиная с версии 3.8 это поведение стало строже и описанный выше сценарий считается ошибочным.

ts
let o0: { [key: string]: number } | { field: number };
/**
 * [< v3.8] Ok
 * [>= v3.8] Error
 * Type 'string' is not assignable to type 'number'.ts(2322)
 */
o0 = { field: 5, dynamicKey: '' };


let o1: { [key: string]: number } | { [key: number]: number };
/**
 * [< v3.8] Ok
 * [>= v3.8] Error
 * Type 'string' is not assignable to type 'number'.ts(2322)
 */
o1 = { dynamicKey: '' };

[КРИТИЧЕСКОЕ ИЗМЕНЕНИЕ] Тип object в JSDoc при активном флаге --noImplicitAny больше не расценивается как any

До текущей версии тип object указанный в JSDoc при активном флаге --noImplicitAny расценивался TypeScript как тип any. Начиная с текущей версии поведение типа object синхронизировано с поведением реализуемым TypeScript.

js
/**
 * @param p0 {Object}
 * @param p1 {object}
 */
export function f(p0, p1){}
ts
// --noImplicitAny: true

import {f} from "./jsdocs";

/**
 * [<  3.8] f(p0: Object, p1: any): void
 * [>= 3.8] f(p0: Object, p1: object): void
 */