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

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

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

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

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

1
2
// @filename: ./SecondLevel.ts
export class SecondLevel {}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// @filename: ./FirstLevel.ts
import { SecondLevel } from './SecondLevel';

export class FirstLevel {
  /**
   * класс SecondLevel используется
   * только как тип
   */
  constructor(secondLevel: SecondLevel) {}
}
1
2
// @filename: ./index.ts
export { FirstLevel } from './FirstLevel';
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// @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.

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

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

1
2
3
// @filename: ./ClassType.ts

export class ClassType {}
1
2
3
4
5
// @filename: ./index.js

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

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

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

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

1
2
3
4
// @filename: ./types.ts

export default class DefaultClassType {}
export class ClassType {}
1
2
3
4
5
// @filename: ./index.ts

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

import DefaultClassType, { ClassType } from './types'; // Ok -> обычный импорт
1
2
3
4
5
6
7
8
9
// @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.
 */

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

1
2
3
4
5
6
// @filename: ./index.ts

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

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// filename: ./types.ts

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

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

export const fe = () => {};
export function fd() {}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 -> *

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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;}

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

1
2
3
// @filename: Base.ts

export class Base {}
1
2
3
4
5
6
7
8
// @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, значение которого может принадлежать к одному из трех вариантов. Но прежде чем познакомиться с каждым из них, необходимо поглубже погрузиться в природу возникновения необходимости в данном механизме.

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

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

1
2
3
4
5
// @filename IPerson.ts

export interface IPerson {
  name: string;
}
1
2
3
4
5
6
7
// @filename action.ts

import { IPerson } from './IPerson';

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

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

1
2
3
4
5
// после компиляции @file action.js

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// @file IPerson.ts

export interface IPerson {
  name: string;
}

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

  toString() {
    return `[person ${this.name}]`;
  }
}
1
2
3
4
5
6
7
8
// @file action.ts

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

function action(person: IPerson) {
  new Person(person);
}
1
2
3
4
5
6
7
// после компиляции @file action.js

import { Person } from './Person';

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

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

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

1
2
3
4
5
6
7
8
9
// @file Person.ts

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

  toString() {
    return `[person ${this.name}]`;
  }
}
1
2
3
4
5
6
7
// @file action.ts

import { Person } from './Person';

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

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

1
2
3
4
5
// после компиляции @file action.js

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

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

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

1
2
3
4
// @filename module.ts

export interface IActionParams {}
export function action(params: IActionParams) {}
1
2
3
4
5
6
7
8
// @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.

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

1
2
3
4
5
6
7
8
9
// @filename: re-export.ts

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

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

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

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

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

1
2
3
4
5
6
7
8
9
// @filename: module-with-side-effects.ts

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

export interface IDataFromModuleWithSideEffects {}

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

import { IDataFromModuleWithSideEffects } from './module';

let data: IDataFromModuleWithSideEffects = {};

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

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

let data = {};

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

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

let data: IDataFromModuleWithSideEffects = {};

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

1
2
3
4
5
6
// @filename: index.js
// после компиляции

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

let data = {};

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

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

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

1
2
3
4
5
6
7
8
9
// @filename: module-with-side-effects.ts

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

export interface IDataFromModuleWithSideEffects {}

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

export interface IDataFromModuleWithoutSideEffects {}
1
2
3
4
5
6
7
8
// @filename: index.ts

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

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

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

1
2
3
4
5
6
7
// после компиляции @file index.js

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

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

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

1
2
3
4
5
6
7
8
// @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 = {};

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

1
2
3
4
5
// @filename: index.js
// после компиляции

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// @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 = {};

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

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

Комментарии