SyntaxError: Unexpected token 'export' — что не так с настройкой
Запускаешь Node-скрипт или Jest-тест и получаешь:
SyntaxError: Unexpected token 'export'Парсер увидел export, но не понял, что это. export — синтаксис ESM-модулей, и он работает только в одном из двух режимов: либо файл сам ESM, либо есть транспайлер, который переводит ESM в CommonJS. Если ни того, ни другого — Node воспринимает это как неожиданный токен и обрывает выполнение.
В моей практике эта ошибка прилетает в трёх контекстах: при работе с Node-скриптами, в Jest-тестах, и при запуске TypeScript-файлов напрямую через Node. Ситуации схожие, но лекарства слегка разные.
Сценарий 1: запускаю Node-скрипт
// scripts/seed.js
export function seedDatabase() { /* ... */ }
seedDatabase();$ node scripts/seed.js
SyntaxError: Unexpected token 'export'Node по умолчанию для .js считает файл CommonJS и не понимает export. Лекарство — одно из:
- Переименовать в
.mjs:mv scripts/seed.js scripts/seed.mjs. - Добавить в
package.json:"type": "module"— тогда все.jsстанут ESM. - Переписать на CommonJS:
module.exports = { seedDatabase }.
Выбор зависит от проекта. Для нового кода я предпочитаю ESM. Если работаю в старой кодовой базе на CommonJS — не ломаю, просто пишу в её стиле.
Сценарий 2: TypeScript через node
$ node scripts/migrate.ts
SyntaxError: Unexpected token 'export'Node не выполняет TypeScript. Если TS-файл написан в ESM-стиле, парсер видит export и валится в первой же строке.
Лекарство — раннер, который понимает TS:
pnpm add -D tsx
pnpm tsx scripts/migrate.tsЧерез флаг --import:
node --import tsx scripts/migrate.tstsx делает on-the-fly компиляцию TypeScript в JS и подсовывает Node. Никакого отдельного tsc-этапа. У меня все скрипты для миграций и сидеров работают именно так.
Сценарий 3: Jest и ESM-зависимости
Самый раздражающий сценарий. Jest по умолчанию умеет CommonJS, и пакеты, которые перешли на ESM (а это сейчас почти все современные), валятся.
SyntaxError: Unexpected token 'export'
> 1 | export { something } from './internal';Jest до сих пор не запускает ESM нативно по умолчанию. Решений несколько:
Подход 1: transformIgnorePatterns
Проблемный пакет нужно прогнать через transformer (например, babel-jest), чтобы он скомпилировался в CommonJS перед запуском. По умолчанию Jest пропускает node_modules через transformer.
// jest.config.js
module.exports = {
transformIgnorePatterns: [
'/node_modules/(?!(uuid|some-esm-package|@scope/.+))',
],
};Регулярка читается как «игнорируй node_modules, кроме перечисленных». Все упомянутые пакеты будут проходить через transformer.
Минус: при каждом запуске тестов transformer прогоняет лишний код, тесты замедляются.
Подход 2: переключиться на Vitest
На новых проектах я не борюсь с Jest и сразу беру Vitest. У него ESM из коробки, никаких transformIgnorePatterns с регулярками. API почти полностью совместимое — миграция занимает день для среднего проекта.
pnpm add -D vitest// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});Подход 3: Jest с экспериментальным ESM
Можно включить нативный ESM в Jest, но это всё ещё experimental:
node --experimental-vm-modules ./node_modules/.bin/jestВ package.json:
{
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
}
}Работает, но с подводными камнями: моки, динамические импорты, jest globals — всё ведёт себя чуть иначе.
Сценарий 4: TypeScript с tsconfig для CommonJS
В tsconfig.json у тебя "module": "commonjs". После сборки получается код вида exports.foo = .... Это работает в Node. Но если ты в каком-то месте проекта забыл, что компиляция в CommonJS, и в исходнике написал export — после сборки Node всё равно увидит CommonJS, не ESM.
Если же "module": "esnext" или "node16", скомпилированный код использует export. Тогда Node должен запускать его как ESM — то есть с "type": "module" в package.json или с расширением .mjs.
Логика простая: модуль-формат на исходниках, в tsconfig и в Node должен совпадать. Если хоть в одном месте рассинхрон — прилетит SyntaxError.
Алгоритм диагностики
- Какой файл выполняется —
.js,.mjs,.cjs,.ts? - Что говорит
package.jsonв полеtype? - Если это TypeScript — кто его запускает: Node напрямую (нельзя), tsx (можно), ts-node, jest, vitest?
- Если Jest — это твой код или пакет из node_modules? Если пакет — добавляй его в
transformIgnorePatterns. - Если в проекте есть собранный код — какой формат используется? Совпадает ли с настройкой Node-окружения?
Маленький чек-лист «когда писать ESM, а когда CJS»
- Новый проект — ESM (
"type": "module","module": "esnext"или"node16"). - Миграция старой кодовой базы — оставляй CJS, не путай команду.
- Скрипты для разовых задач — TS-файл + tsx.
- Тесты — Vitest на новых, Jest с
transformIgnorePatternsна старых.
Unexpected token 'export' звучит страшно, но на деле это сигнал «у нас рассинхрон по модульному формату». Понимаешь, кто кого запускает и в каком режиме — и сразу видно, какой винтик подкрутить.