かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript default import にハマる

TypeScript Express で API を開発していて cors を使おうとした際にエラーになってハマったのでメモ

"TypeError: cors is not a function"

次のようなコードでエラーになった

import * as cors from 'cors';

const app = express();
const router = express.Router();

app.use(cors());

=> TypeError: cors is not a function

cors が function ではない???? なんでや工藤!!!!

CommonJS 形式のモジュールの import 方法に問題があった

import * as cors from 'cors'; これ。
お作法通り import cors from 'cors' だと VSCode の赤線が出て ESLint が下記のようなメッセージを出していた

Module '"/XXX/node_modules/@types/cors/index"' can only be default-imported using the 'esModuleInterop' flagts(1259)
index.d.ts(58, 1): This module is declared with using 'export =', and can only be used with a default import when using the 'esModuleInterop' flag.

import * as cors from 'cors' にすればこのメッセージが消えたので、安易にそうしてしまっていたのが問題だった。

結論: CommonJS なモジュールをインポートする時 default import (import Module from 'module') と namespace import を使った default import (import * as Module from 'module') の挙動には違いがある!

cors (v2.8.5) は module.exports でエクスポートされていた

(function () {
  // 略
  // can pass either an options hash, an options delegate, or nothing
  module.exports = middlewareWrapper;
}());

cf. cors/index.js at master · expressjs/cors · GitHub

module.exports されているモジュールを * as (namespace import) でデフォルトインポートした場合は .default プロパティを通じてでしかアクセスができない

TypeScriptのデフォルトではCommonJSなどのモジュールをES6と同様に扱うため、

  • a namespace import like import * as moment from "moment" acts the same as const moment = require("moment")
  • a default import like import moment from "moment" acts the same as const moment = require("moment").default

cf.
TypeScriptのesModuleinteropフラグを設定してCommonJSモジュールを実行可能とする | DevelopersIO
TypeScript: TSConfig Reference - Docs on every TSConfig option

なので namespace import を使った デフォルトインポートをした場合は下記の書き方らな問題がなかった

import * as cors from 'cors';

app(cors.default());

ただし上記の書き方は見慣れないので、 default import を使ってもエラーを表示しないようにするのが健全

tsconfig の設定で default import を許容するのが健全

esModuleInterop を有効にすると CommonJS なモジュールを default import で扱えるようになる

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    // ...
+   "esModuleInterop": true,
  }
}

👇 default import してもエラーが表示されなくなる

import cors from 'cors';

app(cors);

📝 esModuleInteropallowSyntheticDefaultImports

default import の型の解決自体は allowSyntheticDefaultImports が担っていて、 esModuleInterop はトランスパイル時のコードの変換も含んでいる
esModuleInterop を有効にしたら自動的に allowSyntheticDefaultImports も有効になる
もともとビルドのエラーが発生してないのであれば allowSyntheticDefaultImports を有効にするだけでも良いと思う

allowSyntheticDefaultImports
When set to true, allowSyntheticDefaultImports allows you to write an import like: import React from "react"; instead of: import * as React from "react";
This flag does not affect the JavaScript emitted by TypeScript, it only for the type checking. This option brings the behavior of TypeScript in-line with Babel, where extra code is emitted to make using a default export of a module more ergonomic.

esModuleInterop
Enabling esModuleInterop will also enable allowSyntheticDefaultImports.
cf.
TypeScript: TSConfig Reference - Docs on every TSConfig option
TypeScript の esModuleInterop フラグについて - 30歳からのプログラミング


CommonJS と ESModule

CommonJS

module.exports, exports したモジュールを require() でインポートする形式
Node.js では今も主流の方法

ES Module (ESM)

export default, export したモジュールを import でインポートする形式
主にブラウザのフロントエンドで採用されている方法

フロントエンド開発をしている場合 CommonJS で書かれたライブラリを ES Module でインポートするケースがあるのでこの場合の挙動を抑えておくと良さそう。


CommonJS の export

module.exports

ES Module で言うところの export default に近い

// increment.js
module.exports = (i) => i + 1;

default import

🙆

import increment from './increment';
increment(1); // => 2

// 好きな名前で import できる
import Foo from './increment';
Foo(1); // => 2

* as 使った namespace import

🙅‍♀️

import * as increment from '../libs/increment';
increment(1);
// => TypeError: increment is not a function

🙆

// module.export されたモジュールは default という名前で export されている
import * as increment from './increment';
increment.default(1); // => 2

// * as も好きな名前でインポートできる
import * as Foo from './increment';
Foo.default(1); // => 2

📝 module.exports = { default: Module, key: Module }

レアな書き方だと思うけど、default キーを使ったオブジェクトが module.exports されているモジュールを ESM でインポートした場合は .default を通じてでないと Module にアクセスができない
default キー以外のプロパティは exports されたものと同じ扱いになる

default import

// increment.js
module.exports = {
  default: (x) => x + 1,
  name: 'increment',
};

🙅‍♀️

import increment from "./increment";
increment(2);
// => TypeError: (0 , _incrementDefault.default) is not a function

module.exports = { default: Module } の時はオブジェクトとして export されているので increment はオブジェクトになるのでそのまま関数を呼び出すことは当然できない

🙆

import increment from "./increment";
increment.default(2); // => 3
increment.name; // => "increment"

// 好きな名前で import できる
import Bar from './increment';
Bar.default(3); // => 4
Bar.name; // => "increment"
⚠ named export できるが default は特殊な名前なので注意が必要

🙅‍♀️

import { default, name } from './increment';
name; // => "increment"
default(1);
// => SyntaxError: Unexpected token

🙅‍♀️

import { default as Alias } from './increment'
Alias(1);
// => TypeError: (0 , _incrementDefault.default) is not a function

console.log(Alias);
// => { default: ƒ default(), name: "increment" }

🙆

import { default as Alias } from './increment'
Alias.default(1); // => 2
Alias.name; // => "increment"
  • default プロパティは特殊な名前なのでそのまま named import できない。
  • { default as Alias } を使う必要があるが、Alias は default で定義された値ではなくモジュール全体をデフォルト import した際のオブジェクトになる
  • module.exports = { default: Module } されているモジュールは as を使ってインポートしても default import したときと同じになる

* as 使った namespace import

// increment.js
module.exports = {
  default: (x) => x + 1,
  name: 'increment',
};

🙅‍♀️

import * as Alias from './increment'
// default 以外のプロパティにはアクセスできる
Alias.name; // "increment"
Alias(1);
// => Alias is not a function

Alias.default(1);
// => Alias.default is not a function

console.log(Alias);
// => { name: increment", default: { default: ƒ default(), name: "increment" } }

* as import した際は .default でインポートされたオブジェクトにアクセスできていたので Alias.defaultmodule.exports されたオブジェクトになっている

🙆

import * as Alias from './increment'
Alias.name; // "increment"
Alias.default.default(1); // => 2
Alias.default.name; // "increment"

module.exports = { default: Module } のように default キーが使われたオブジェクトが module.exports されているモジュールを * as import すると Module にアクセスするには default.default としなければならなくなる


exports.Module

ES Module で言うところの export に近い

// decrement.js
exports.decrement = (x) => x - 1;

default import

🙆

import Alias from "./decrement"
console.log(Alias)
// => { decrement: f() }
Alias.decrement(2); // => 1

default import したオブジェクト内に exports したモジュールが含まれている

* as 使った namespace import

🙆

import * as Alias from "./decrement"
console.log(Alias)
// => { decrement: f(), default: { decrement: ƒ () } }
Alias.decrement(2); // => 1
Alias.default.decrement(3); // => 2

* as を使った namespace import だとインポートしたオブジェクト内に default というプロパティが含まれるが exports されたモジュールには default import と同じ方法でアクセスができる

exports.Module = Xmodule.exports = { Module: X } と同じ

exports が使われている CommonJS のモジュールを使用する際は named import を使うのが良い

exports されたモジュールは * as を使うかどうかに関わらず default import せずに named import をするのが分かりやすい

import { decrement } from "./decrement";
decrement(2); // => 1

所感

"ふんいき" でコード書いてると稀によくある。。。
少しだけ理解が進んだので良かった。
ちゃんと理解していくのが真の近道ですね…

おわり。


[参考]