Всем привет, хабровчане! Я (не)начинающий разработчик с относительно небольшим стажем, который пытается углубить свои знания в любимой технологии. В работе и повседневной жизни очень часто приходится работать с языком TypeScript, который мне очень нравится, но к своему стыду, сам очень плохо знаком с настройкой его конфигурации, поэтому решил восполнить этот пробел, ведя собственный Today I Learned. Некоторые опции tsconfig являются очень простыми и понятными. Другие же заставляют знатно напрячься. И даже если поверхностное назначение какой-то настройки является понятным, все равно возникает желание разобраться с принципом ее действия, понять, на какие структурные аспекты проекта она влияет, а также узнать, а как вообще людям жилось до ее появления.

Как раз об одном из них и пойдет разговор в этой статье, а именно об esModuleInterop. Действие опции проверялось при попытке подружить CommonJS-модуль с ES-модульным проектом. Поверхностная гуглешка не дала исчерпывающий ответ на ряд моих вопросов, поэтому приходилось обращаться к спецификации ES6, документации tsconfig (упаси боже читать документацию (шутка)), в личные блоги авторитетных в сообществе дядек (https://2ality.com/2014/09/es6-modules-final.html) и к описаниям модульных систем. На основе найденной информации я составил небольшое резюме, с попыткой собрать материал во едно. Надеюсь, кому-то он покажется интересным. Приятного чтения!

Небольшая предыстория

В июне 2015 года на свет появилась спецификация EcmaScript2015, которая подарила разработчикам нативную модульную систему с приятным синтаксисом по работе с зависимостями. До этого момента использовались синтетические модульные системы, которые пытались ограничить область видимости языковых единиц, к примеру IIFE, CommonJS, AMD с ее реализацией RequireJS, UMD и другие (источник).

Но сегодняшний день большая часть npm-пакетов для NodeJS написано с использованием модульной системы CommonJS. Попытка подружить их приводит к ряду (на мой взгляд) не критичных проблем. Однако в транспилятор tsc заложен механизм адаптации CommonJS модулей. Но сначала рассмотрим, как работали импорты до появлениях этих инструментов в babel и tsc.

Мой tsconfig:

{
  "target": "es2022",
  "module": "commonjs",
  "rootDir": "./src",
  "outDir": "./build",
  "moduleResolution": "node",
  // "esModuleInterop": true,
  // "allowJs": true,
  "importHelpers": false,
  "forceConsistentCasingInFileNames": true,
  "strict": true,
  "skipLibCheck": true
}

Использование CommonJS-пакетов с отключенными опциями

На самом деле при отсутствии этих опций мы уже можем работать с commonjs модулем. Чтобы импортировать сущности из этого модуля, можно воспользоваться именованным импортом или импортом пространства имен (не путать с namespace(-ом)).

Именованный импорт

Рассмотрим следующий пример:

// commonjs.js
module.exports = {
    sum: function (a, b) {
        return a + b;
    },
    aboba: function() {
        return undefined
    }
};

// index.ts
import { aboba, sum } from './commonjs.js';

console.log(sum(1, 2));
console.log(aboba);

В результате имеем следующий транспилированный код:

// index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const commonjs_1 = require("./commonjs");
console.log((0, commonjs_1.sum)(1, 2));
console.log(commonjs_1.aboba);

Здесь можно увидеть, что tsc преобразовал код index.ts в commonjs-модуль. Это можно понять, взглянув на третью строчку, где транспилятор добавил признак "__esModule", чтобы пометить его как нативный es6 модуль (роль этого флага будет подробно рассматриваться в будущих примерах). В примере показано, что преобразованный index.js с помощью require загружает модуль commonjs.js, после чего идет обращение к импортированным sum и aboba.

Давайте еще остановимся на моменте вызова функции sum, которая почему-то оборачивается в круглые скобки, через запятую добавляется какой-то ноль, и только потом осуществляется сам вызов с переданными аргументами. На самом деле это прием, который использует транспилятор при импорте с помощью синтаксиса es6. Прием использует оператор ",", который возвращает последний элемент из этой последовательности. В данном случае будет возвращена ссылка на функцию sum. Дело в том, что при вызове sum напрямую через объект модуля, то контект this для этой функции будет определен как объект module.exports модуля commonjs.js. Извлекая sum из контекста модуля, транспилятор помещает ее в глобальный контекст, таким образом избегая нежелательных эффектов.

require-синтаксис

TypeScript предоставляет особый вид синтаксиса, который позволяет импортировать CommonJS и AMD модули подобно тому, как это делается в указанных модульных системах (ссылка на документацию https://www.typescriptlang.org/docs/handbook/2/modules.html).

// index.ts
import module = require('./commonjs.js');

module.sum(1, 2);

Результат транспиляции:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const commonjsModule = require("./commonjs.js");
commonjsModule.sum(1, 2);

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

Еще один минус в пользу отказа от такого импорта - такой синтаксис импорта встроен в сам язык и не является частью стандарта es-модулей.

Импорт пространства имен

Следующий пример импорта:

import * as commonjsModule from './commonjs.js';

commonjsModule.sum(1, 2);

Результат транспиляции:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const commonjsModule = require("./commonjs.js");
commonjsModule.sum(1, 2);

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

Промежуточное резюме

Наличие различных модульных систем, которые имеют разную внутреннюю организацию порождает множество вариации синтаксиса импорта/экспорта для работы с уже существующими модульными системами. Другая проблема - проблема с определениями типов при импорте. Современные версии TS-могут гибко определять импортируемые типы, однако для того же пространства имен происходит путаница.

Рассмотрим следующий пример для импорта пространства имен:

// commonjs.js
module.exports = function () { }

// index.ts
import * as commonjsModule from './commonjs.js';

commonjsModule();

В данном примере среда распознает commonjsModule одновременно и как module и как функцию без аргументов. Возможно в более ранних версиях TS сделал бы более жесткое предупреждение. Вообще стандарт ES6 для модулей требует, чтобы импорт namespace(-а) представлял собой объект с набором свойств, который описывает интерфейс модуля. А это значит, что на уровне организации ES6 и CommonJS модулей имеется несовместимость, поскольку CommonJS не удовлетворяет одному из требований спецификации (спецификация, документация tsconfig).

Еще одна проблема взаимодействия ES6 и CommonJS модулей - разная реализация импорта по умолчанию. Синтаксис ES6 предоставляет возможность экспортировать сущность через конструкцию export default (одна сущность на весь модуль). Такая конструкция дает разработчикам некоторые приятные возможности:

  • разноcить сущности отдельно по собственным модулям (как, например, это реализовано в языке Java);

  • пользоваться удобной и простой конструкцией импорта сущности по умолчанию.

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

Напротив, CommonJS модули не могут использовать именованный и дефолтный экспорт одновременно.

// Именованный
module.exports = {
    sum: (a, b) => a + b,
    aboba: function() { }
};

// Импорт по умолчанию
module.exports = function () {

}

Отсюда вытекает еще одна задача на пути к достижению совместимости ES и CommonJS -модулей.

Использование транспилятора для адаптации CommonJS модулей к ES6

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

В контексте TS проблема совместимости решается при помощи опции esModuleInterop, которая в процессе компиляции добавляет хэлперы в ES6-модули, которые осуществляют преобразование зависимостей (CommonJS-модулей) в совместимые модули. Эти функции имеют имена _importDefault и _importStar.

__importDefault

Рассмотрим следующий пример:

// commonjs.s
module.exports = {
    sum: (a, b) => a + b,
    aboba: function() {}
};

// index.ts
import commonjsModule from './commonjs.js';

commonjsModule.sum(1, 2);

В результате транспиляции получаем:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const commonjs_1 = __importDefault(require("./commonjs.js"));
commonjs_1.default.aboba();

Как видно из примера загрузка CommonJS-модуля осуществляется при помощи require, вызов которой оборачивается в хэлпер. Хэлпер устроен достаточно просто. Он проверяет наличие признака __esModule. Если он выставлен, то переданный модуль является нативным ES6 модулем (как, например, index.ts, для которого транспилятор специально устанавливал этот флаг) и он возвращается как есть. Иначе импортированный объект оборачивается в default.

Кажется, что такая конструкция немного спорная, потому что в default помещается весь объект нашего модуля. Однако заранее узнать, что именно наодится в module.exports (объект со свойствами|константа|вызываемое выражение и другие) - невозможно. Подобное решение в целом удовлетворяет спецификации.

Примечание: В приведенном примере используется конструкция синтетического дефолтного импорта, которая доступна благодаря опции allowSyntheticDefaultImports, которая автоматически устанавливается в true, когда выставлен флаг esModuleInterop. В обычной ситуации TS-анализатор выкинул бы ошибку, что для модуля с отсутсвующим default-экспортом такая конструкция недопустима. Однако выставленный набор опции позволяет работать с commonjs модулем подобным образом.

__importStar

Рассмотрим следующий пример с тем же CommonJS модулем, но с импортом простнаства имен:

import * as commonjsModule from './commonjs.js';

commonjsModule.sum(1, 2);
commonjsModule.aboba();

Результат:

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
const commonjsModule = __importStar(require("./commonjs.js"));
commonjsModule.sum(1, 2);
commonjsModule.aboba();

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

Резюме

В итоге получаем, что опция esModuleInterop с allowSyntheticDefaultExport (включается автоматически с esModuleInterop) облегчет нам взаимодействие с другими модульными системами, позволяя нам как разработчикам писать более привычные импорты с использованием современного синтаксиса с корректной поддержкой типов.

Немного про importHelpers

Как вы могли заметить, реализация совместимости модулей создает достаточно много бойлерплейта, который увеличивает размер скомпилированного кода. Подобный код будет добавлен в каждый модуль, использующий импорт CommonJS. Чтобы решить эту проблему можно воспользоваться флагом importHelpers, который будет брать реализацию этих функций из библиотеки tslib (ее так же нужно будет добавить в ваш package.json)

P.S.

Спасибо всем, кто дочитал до конца! Буду рад комментариям и предложениям

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


  1. flancer
    23.07.2025 06:34

    CJS-модуль импортируется напрямую в ES6-модуль средствами JavaScript без прекомпиляции, если делать так:

    // index.js
    import {default as cjs} from './commonjs.js';
    
    console.log(cjs.sum(1, 2));
    console.log(cjs.aboba());
    • чтобы скрипт index всегда запускался в ES6-режиме, у него должно быть расширение *.mjs;

    • чтобы скрипт commonjs всегда считывался в режиме CommonJS, у него должно быть расширение *.cjs;

    • если у скрипта в пакете расширение просто *.js, то формат модулей регулируется через type в package.json (commonjs | module);

    Я в TypeScript не силён, но думаю, что можно его заставить транспилировать код в ES6-формат, а не в CommonJS.

    Я уверен, что если бы CommonJS был достаточно хорош, не придумывали бы ES6, но если всё-таки придумали ES6, то должны быть весомые поводы транспилировать свой проект в CommonJS.


    1. polRk
      23.07.2025 06:34

      Как пример настройки для esm only сборки

      https://github.com/ydb-platform/ydb-js-sdk/blob/main/tsconfig.json