Перейти к содержанию

Импорт и экспорт только типа

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

Предыстория возникновения import type и export type

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

// @filename: ./SecondLevel.ts
export class SecondLevel {}
// @filename: ./FirstLevel.ts
import { SecondLevel } from './SecondLevel';

export class FirstLevel {
  /**
   * класс SecondLevel используется
   * только как тип
   */
  constructor(secondLevel: SecondLevel) {}
}
// @filename: ./index.ts
export { FirstLevel } from './FirstLevel';
// @info: скомпилированный проект

// @filename: ./SecondLevel.js
export class SecondLevel {}

// @filename: ./FirstLevel.js
/**
 * Несмотря на то что от класса SecondLevel не осталось и следа,
 * модуль *, в котором он определен, все равно включон в сборку.
 */
import './SecondLevel'; // <-- *
export class FirstLevel {
  /**
   * класс SecondLevel используется
   * только как тип
   */
  constructor(secondLevel) {}
}

// @filename: ./index.js
export { FirstLevel } from './FirstLevel';

Поскольку при использовании допустимых JavaScript конструкций исключительно в качестве типа, было бы разумно ожидать, что конечная сборка не будет обременена модулями в которых они определены. Кроме того конструкции присущие только TypeScript, хотя и не попадают в конечную сборку, в отличие от модулей в которых они определенны. Если в нашем примере поменять тип конструкции SecondLevel с класса на интерфейс, то модуль ./FirstLevel.js все равно будет содержать импорт модуля ./SecondLevel.js содержащего экспорт пустого объекта export {};. Не лишним будут обратить внимание, что в случае с интерфейсом, определяющий его модуль мог содержать и другие конструкции. И если бы среди этих конструкций оказались допустимые с точки зрения JavaScript, то они, на основании изложенного ранее, попали бы в конечную сборку. Даже если бы вообще не использовались.

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

import type и export type - форма объявления

Форма уточняющего импорта и экспорта только типа включает в себя ключевое слово type идущее следом за ключевым словом import либо export.

import type { Type } from './type';
export type { Type };

Ключевое слово type можно размещать в выражениях импорта, экспорта, а также ре-экспорта.

// @filename: ./ClassType.ts

export class ClassType {}
// @filename: ./index.js

import type { ClassType } from './types'; // Ok -> импорт только типа

export type { ClassType }; // Ok -> экспорт только типа
// @filename: ./index.js

export type { ClassType } from './types'; // Ok -> ре-экспорт только типа

Единственное отличие импорта и экспорта только типа от обычных одноименных инструкций состоит в невозможности импортировать в одной форме обычный импорт\экспорт и по умолчанию.

// @filename: ./types.ts

export default class DefaultClassType {}
export class ClassType {}
// @filename: ./index.ts

// пример с обычным импортом

import DefaultClassType, { ClassType } from './types'; // Ok -> обычный импорт
// @filename: ./index.ts

// неправильный пример с импортом только типа

import type DefaultClassType, { ClassType } from './types'; // Error -> импорт только типа

/**
 * [0] A type-only import can specify a default import or named bindings, but not both.
 */

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

// @filename: ./index.ts

// правильный пример с импортом только типа

import type DefaultClassType from './types'; // Ok -> импорт только типа по умолчанию
import type { ClassType } from './types'; // Ok -> импорт только типа

Импорт и экспорт только типа на практике

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

// filename: ./types.ts

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

export const o = { person: '🧟' };

export const fe = () => {};
export function fd() {}
import type {
  o,
  fe,
  fd,
  ClassType,
  IInterfaceType,
} from './types'; // Ok

/**
 * * - '{{NAME}}' cannot be used as a value because it was imported using 'import type'.
 */

let person = o.person; // Error -> *
fe(); // Error -> *
fd(); // Error -> *
new ClassType(); // Error -> *

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

import type {
  o,
  fe,
  fd,
  ClassType,
  IInterfaceType,
} from './types';

/**
 * v2, v3 и v4 используют механизм
 * запроса типа
 */

let v0: IInterfaceType; // Ok -> let v0: IInterfaceType
let v1: ClassType; // Ok -> let v1: ClassType
let v2: typeof fd; // Ok -> let v2: () => void
let v3: typeof fe; // Ok -> let v3: () => void
let v4: typeof o; // Ok -> let v4: {person: string;}

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

// @filename: Base.ts

export class Base {}
// @filename: index.ts

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

/**
 * Error -> 'Base' cannot be used as a value because it was imported using 'import type'.
 */
class Derived extends Base {}

Вспомогательный флаг --importsNotUsedAsValues

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

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

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

// @filename IPerson.ts

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

import { IPerson } from './IPerson';

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

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

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

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

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

// @file IPerson.ts

export interface IPerson {
  name: string;
}

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

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

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

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

import { Person } from './Person';

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

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

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

// @file Person.ts

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

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

import { Person } from './Person';

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

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

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

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

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

Рассмотрим пример состоящий из двух модуле. Первый модуль экспортирует объявленные в нем интерфейс и функцию использующая этот интерфейс в аннотации типа своего единственного параметра. Второй модуль лишь ре-экспортирует интерфейс и функцию из первого модуля.

// @filename module.ts

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

import { IActionParams, action } from './types';

/**
 * Error -> Re-exporting a type when the '--isolatedModules' flag is provided requires using 'export type'
 */
export { IActionParams, action };

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

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

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

// @filename: re-export.ts

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

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

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

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

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

// @filename: module-with-side-effects.ts

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

export interface IDataFromModuleWithSideEffects {}

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

import { IDataFromModuleWithSideEffects } from './module';

let data: IDataFromModuleWithSideEffects = {};

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

// @filename: index.js
// после компиляции

let data = {};

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

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

let data: IDataFromModuleWithSideEffects = {};

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

// @filename: index.js
// после компиляции

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

let data = {};

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

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

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

// @filename: module-with-side-effects.ts

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

export interface IDataFromModuleWithSideEffects {}

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

export interface IDataFromModuleWithoutSideEffects {}
// @filename: index.ts

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

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

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

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

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

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

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

// @filename: index.ts

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

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

Импорты модулей будут отсутствовать.

// @filename: index.js
// после компиляции

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

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

// @filename: 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 = {};

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

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