「TypeScriptが当たり前」になった世界において、ESモジュール本来の運用に必要な考え方と設定とは

投稿日時:2025/03/20 22:35
藤 吾郎(gfx)のアイコン

Starley株式会社 / ソフトウェアエンジニア

藤 吾郎(gfx)

こんにちは、藤吾郎(gfx)と申します。Starleyという会社でおしゃべりAIアプリ「Cotomo」を開発しています。TypeScript歴は10年くらいです。

はじめに - TypeScriptが当たり前になった世界

今年(2025年)はTypeScriptがリリースされて13年、ESモジュールが導入されたES2015のリリースから10年が経ちます。今やJavaScriptプロジェクトにおいては、TypeScriptが当たり前の世界になってきました。つまり「JavaScriptプロジェクトの実装言語のデフォルトはTypeScript」という状況にかなり近づいています。

TypeScriptが当たり前の世界とは、JavaScript処理系がデフォルトでTypeScriptをサポートしている世界のことです。Node.jsでTypeScriptサポートが始まり、BunやDenoのように最初からTypeScript前提で設計されたサーバーサイドのJavaScript処理系もメジャーになりつつあります。WebブラウザにおけるTypeScript処理系こそまだないものの[2]、Next.jsなどのWebアプリケーションフレームワークでは、デフォルトの実装言語としてTypeScriptが使えるようになっています。

特にNode.jsにおけるTypeScriptサポート[3]は歴史的な転換点だと言えます。現行のLTSであるNode.js 22でも--experimental-strip-typesオプション付きならTypeScriptを直接実行できますし、最新版(Current)であるNode.js 23ではオプションなしで直接実行が可能です。次のLTSとなるNode.js 24でも、引き続きオプションなしでTypeScriptを直接実行できると考えられます[4]

Node.jsのTypeScriptサポートはまだ実験的な機能で、現在はこの機能を使うと「実験的機能である」という警告が出ます。この警告がいつ消えるのかは不明ですし、警告が消えるまでは仕様変更もあるかもしれません。それでもNode.jsの歴史的には、大きな転換です。

筆者は5年ほど前に「がんばらない」をテーマとしたTypeScriptの記事を何度か書いています[1]。それは主に「JavaScriptで書かれたプロジェクトをTypeScriptに移行する」ことを目的とする記事でした。これは今でも「移行」には役立つと思います。しかし、現代のように最初からTypeScriptで書く場合には、tsconfigのさまざまなstrict系オプションもまとめて単にstrict: trueでよく、それ以外のケースを考えることは不要になりました。

Node.jsのTypeScriptサポートにおいて注意すべきこと

Node.jsでTypeScriptサポートが開始されたとはいえ、TypeScriptコンパイラが不要になるものではありません。なぜなら特長もはっきりしていて、まず型チェックは行いません。型チェックを行うには、引き続きTypeScriptコンパイラを使う必要があります。

サポートされている構文も制限されており、デフォルトでは「型の削除によって実現できる機能」だけです。これは、TypeScript 5.8で導入されるerasableSyntaxOnlyオプション[5]で使える範囲に相当します。具体的にはenum, namespace, module, parameter properties, import aliasesがサポートされません。enumとparameter propertiesは比較的よく使われる構文なので、Node.jsのTypeScriptサポートではデフォルトで有効にならないことに注意してください。

これらの構文も、Node.jsに--experimental-transform-typesオプションを与えるとサポートされますが、個人的には--experimental-transform-typesは使わず、TypeScriptのerasableSyntaxOnlyオプションを有効にしておく方が健全だと思います。「TypeScriptは型注釈のみを提供する」と考える方がJavaScriptとの乖離が少なく、考えることがシンプルで済みます。

また、--experimental-transform-typesが必要なのはただの糖衣構文なので、本来は使う必要がありません。これらはTypeScriptの初期の開発フェーズにおいて、「TypeScriptは型注釈に徹する」という方針が定まってなかった時代に追加された遺産(レガシー)であると考える方がよいでしょう。

さらにNode.jsのTypeScriptサポートでは、tsconfigに依存した機能がまったく使えません。特にtargetによる古いJavaScriptへのダウンレベリングや、pathsによるインポートパスの置き換えが使えません。前述のように型チェックのためにTypeScriptコンパイラを使うのでtsconfigがまったく不要になるわけではありませんが、「Node.jsのTypeScriptサポートではtsconfigが完全に無視される」ということは意識しておかなければいけません。

モジュールのインポートにおいてはパスと拡張子に注意する

最後に注意することとして、ESモジュール(ESM)をインポートするパスは、拡張子を含めた実在するパスを記述します。例えば、次のようなファイルがあるとします(ここではコンパイル前も後もESMであることを強調するため、拡張子に.tsではなく.mtsを使っていますが、実際の運用では.tsでもかまいません)

// add.mts
export function add(a: number, b: number) {
  return a + b;
}

このファイルを、同じディレクトリにあるmain.mtsというファイルからインポートするときには、次のように完全なパスを指定する必要があります。

// main.mts
import { add } from "./add.mts";

console.log("1 + 2 =", add(1, 2)); // => 3

なお、このファイルはNode.js 23でオプションなしで実行できます(DenoやBunでも同様に実行できます)

node main.mts
1 + 2 = 3

このインポートにおいては、JavaScriptファイルをインポートするときのように拡張子が勝手に補完されたり、ディレクトリを指定するとindex.mtsが参照できたりする機能はありません。

こういったポリシーはESMの本来あるべき姿なので、おそらく今後も変更されることはないでしょう。しかし、これまでのTypeScriptのデファクトスタンダードとは大きく異なる振る舞いになるため、運用には少し注意が必要です。

TypeScriptにおけるESMの拡張子はどのように運用されてきたか

TypeScriptにおけるESMの構文では、長らく「拡張子なし」がデファクトスタンダードでした。つまり、先ほどのようにmain.mtsからadd.mtsをインポートするときは、次のように拡張子なしのファイル名を使っていました。

import { add } from "./add";

これは本来のESMとは異なるスタイルですが、典型的にはコンパイル時にimport文がrequire関数に変換されるため、最終的な実行はrequire関数のパスの解釈に委ねられていたのです。これにより、"./add"というパスは、実行時に"./add.js"だったり"./add/index.js"だったり、JavaScriptランタイムの提供するrequire関数の仕様に任せた振る舞いとなっていました[7]

実行時に実在するパスは何か? という問題

ところがJavaScriptにおけるESMのネイティブサポートが普及してくると、上記の拡張子のないimport文が実行時エラーを引き起こす可能性が出てきました(webpackなどのモジュールバンドラではまた振る舞いが違うため、ここでは話を単純化するためにNode.js環境のみを想定します)

このimport文は、tsconfigで"module": "ES2015"のようにESMを使う設定になっていると、requireに置き換えられることなくそのまま残ります。ESMはパスの変換を行わないため、"./add"というファイルが存在しないというランタイムエラーになります。

そのためTypeScriptのソースコードでは次のように、実行時に存在するパスである"./add.mjs"をインポートしなければならなくなりました。

// main.mts
// 通常のTypeScriptコンパイラによるトランスパイルをするプロジェクトにおけるESMの運用方法:
import { add } from "./add.mjs"; // TS fileがTS fileをimportするときでも.jsや.mjsが必要

console.log("1 + 2 =", add(1, 2)); // => 3

一方で、Node.jsのTypeScriptサポートによる実行時には、この"./add.mjs"というファイル名はエラーになります。前述のようにNode.jsのESMでは実行時に存在するパスを指定する必要がありますが、TypeScriptを直接実行するならadd.mjsというファイルはどのタイミングでも存在しないからです。

ここまで説明してきたadd.mtsmain.mtsの関係において、Node.jsのTypeScriptサポートで実行するには、先ほどのようにadd.mtsをインポートしなければなりません。

// main.mts
// Node.jsのTypeScriptサポートにおけるESMの運用方法:
import { add } from "./add.mts"; // Node.jsのTypeScriptサポートでは拡張子.ts/.mtsが必要

console.log("1 + 2 =", add(1, 2)); // => 3

いつでもESM本来の運用をする:allowImportingTsExtensionsの導入

ここまでの前提を踏まえるなら、TypeScriptコンパイラでJavaScriptにトランスパイルするプロジェクトであっても、TypeScriptを直接実行する場合と同じように.mtsの拡張子を使用するESM本来の運用をしたいところです。

この実現は、まずTypeScript 5.0(2023年3月リリース)allowImportingTsExtensions: trueが導入されるところから始まりました[9]

allowImportingTsExtensions: trueは、ESMのインポート時にTypeScriptの拡張子.ts, .mts, .tsxなど)を指定できるようにするオプションです。これを有効にすると、TypeScriptのソースコードでは"./add.mts";というファイル名でインポートできます。

ただしこのオプションは、当初noEmit: trueと併用しなければなりませんでした。noEmit: trueは、トランスパイルしたJavaScriptを出力しないという設定です。つまりtscコマンドを実行しても、コンパイルはできますがJavaScriptファイルが生成されず、実行はできません。

allowImportingTsExtensions: trueによって.mts拡張子をつけたTypeScriptファイルを実行するには、カスタムコンパイラが必要です。例えばtsimpはまさにそのようなツールで、TypeScriptコンパイラを内蔵しており、TypeScriptファイルの型チェックをしつつ、Node.jsでTypeScriptコードを実行できます(同様のツールにはtsxやts-nodeがありますが、型チェックをしつつESMに正式に対応しているのは筆者の知る限りtsimpだけです)

続・ESM本来の運用をする:rewriteRelativeImportExtensionsの導入

TypeScript開発チームがallowImportingTsExtensionsを上記のような仕様にしたのは、TypeScriptコンパイラが型注釈の削除だけではなく、ランタイムに影響するようなコードの書き換えを行うことを嫌がったからだと考えられます。

しかし、これはあまりにも厳しい制約で運用が難しいため、TypeScript 5.7(2024年11月リリース)ではさらなるオプションが導入されました。それがrewriteRelativeImportExtensions: trueです[12]。このオプションによりTypeScriptファイルのimport文は、次のように実行時に影響を及ぼす形で書き換え(rewrite)られます。

もともとのTypeScriptのソースコード:main.mts

// main.mts
import { add } from "./add.mts";

console.log("1 + 2 =", add(1, 2));

コンパイル後のJavaScriptのソースコード:main.mjs

import { add } from "./add.mjs";

console.log("1 + 2 =", add(1, 2));

次のtsconfig.jsonのように、このオプションとallowImportingTsExtensions: trueを同時に有効にすると、noEmit: trueが必要なくなり、動作するJavaScriptファイルを生成できます。

{
  "compilerOptions": {
    "target": "ES2024",
    "lib": ["ES2024", "DOM"],
    "module": "ES2022",
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./build",
  }
}

rewriteRelativeImportExtensionsの例外的に発生する注意点

rewriteRelativeImportExtensionsの注意点として、ダイナミックインポート時にファイル名として文字列リテラルではなく動的な式を使う場合には、動的な拡張子の書き換えのため、オーバーヘッドのあるランタイムコードが生成されます。例えば次のようなTypeScriptコードがあるとします。

const dynamicExpr = "./add.mts";
const { add } = await import(dynamicExpr); // ファイル名がリテラルではない

console.log("1 + 2 =", add(1, 2));

これを上記の設定でコンパイルすると、次のようなJavaScriptコードが生成されます。

var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
    if (typeof path === "string" && /^\.\.?\//.test(path)) {
        return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
            return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
        });
    }
    return path;
};
const dynamicExpr = "./add.mts";
const { add } = await import(__rewriteRelativeImportExtension(dynamicExpr));
console.log("1 + 2 =", add(1, 2));
export {};

この__rewriteRelativeImportExtension()関数の詳細を理解する必要はありませんが、控えめにいって複雑なコードであり、TypeScript開発チームが導入を嫌がったのも理解できます。

ただし、これが発生するのはダイナミックインポートの引数に動的な式を与えたときのみです。普通のアプリケーションでそのように指定しなければならないケースはほとんどないないので、基本的にはコンパイル時にのみ.mts.mjsあるいは.ts.jsといった変換が行われるだけと考えてよいでしょう。

rewriteRelativeImportExtensionsが必要なプロジェクトと不要なプロジェクト

このrewriteRelativeImportExtensions: trueオプションこそ、TypeScriptコンパイラとNode.jsのTypeScriptサポートの間にあるギャップを埋めるものであり、TypeScriptにおけるESMの運用をよりスムーズにするものです。TypeScriptが2012年にリリースされて12年、ES2015でESMが導入されて10年近く経って、ようやくTypeScriptで本来のESMを使う、つまり「importするパスに実在するファイルを指定する」ことが、クライアントサイドでもサーバーサイドでも可能になったのです。

今後このrewriteRelativeImportExtensions: trueは、特にTypeScriptで開発しているライブラリのプロジェクトではほとんど必須になっていくのではないかと思います。本来のESM運用をしたいこと、コンパイル後のJavaScriptファイルをESMにしたいこと、そしてテストを高速に実行するためにNode.jsのTypeScriptサポートを使いたいといった動機がありつつ、成果物はJavaScriptでリリースしなければならないからです。

ライブラリ以外でrewriteRelativeImportExtensions: trueが必要かどうかは、プロジェクトによるでしょう。例えば、Next.jsではallowImportingTsExtensions: trueだけでクライアントサイドもサーバーサイドも本来のESM運用が行えるため、rewriteRelativeImportExtensions: trueは不要です。プロジェクトの性質に合わせて、rewriteRelativeImportExtensions: trueを有効にするかどうかを判断してください。

本来のESM運用のために必要なVS CodeとESLintの設定

本記事で紹介した本来のESM運用を行うために、VS CodeとESLintも設定しておくとよいでしょう。

VS Codeの設定は簡単で、settings.jsonに次のような設定を加えるだけです。

{
  "typescript.preferences.importModuleSpecifierEnding": "js"
}

設定名と値が分かりやすいとはいえませんが、これによりallowImportingTsExtensions: trueのもとでimport文を加えるときに、VS Codeが自動で.mts.tsを補ってくれるようになります。

ESLintでは、eslint-import-pluginimport/extensionsが必要です。設定例は次の通りです。

import importPlugin from "eslint-plugin-import";
import js from "@eslint/js";
import ts from "typescript-eslint";

export default ts.config(
  {
    ignores: [".tsimp/**/*.*", "build/**/*.*", "*.mjs"],
  },
  js.configs.recommended,
  ts.configs.recommended,
  importPlugin.flatConfigs.recommended,
  {
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
    },
    rules: {
      "import/extensions": [
        "warn", // 強く強制したければ severity は error に設定すること
        "always", // 拡張子は常につける(本来のESM運用)
      ],
    },
  },
);

まとめ

本記事では、Node.jsのTypeScriptサポートと最近のTypeScriptにおけるESMの運用事情を、特に「本来のESM運用(常に実在するパスを指定する)という観点で紹介しました。

まとめると次のようになるでしょう。

  • Node.jsはバージョン22からTypeScriptのサポートを開始
    • ESMは「本来のESM運用」のみをサポート
  • TypeScriptでは次の設定をプロジェクトの特性に合わせて使うことで本来のESM運用が可能
    • バージョン5.0から導入されたallowImportingTsExtensions: true
    • バージョン5.7から導入されたrewriteRelativeImportExtensions: true
  • VS CodeとESLintも本来のESM運用をサポートするための機構があるので使うべし

ところで、筆者は先日『JavaScriptプログラマーのためのTypeScript厳選ガイド』というTypeScriptの解説本を執筆しました。

この本はrewriteRelativeImportExtensions: trueもNode.jsのTypeScriptサポートも存在しないタイミングで書いたので、サンプルコードではtsimpを使って「本来のESM運用」を徹底しているものの、その実運用についてはお茶を濁す感じで書き終えてしまいました。それが本記事で、本来のESMの運用方針を完全な形で示すことができたので安堵しています。逆に、この本と本記事は「TypeScriptでも本来のESM運用をするべき」というポリシーが共通するだけで、解説としてはまったく重複していませんので、本記事で筆者を知ってくださった方はぜひ『TypeScript厳選ガイド』も手に取っていただければ幸いです。

制作・編集:はてな編集部

脚注
  1. TC39への型注釈仕様の提案はありますが、現在ステージ1でそれほど活発に議論されているわけでもなく、仮に提案がまとまるとしてもまだまだ時間がかかりそうです。

  2. 参照:Node.js「Introduction to TypeScript

  3. 参照:Node.js「Running TypeScript Natively

  4. 現在は転職情報サイトAMBIに掲載されている「TypeScript再入門 ― 「がんばらないTypeScript」で、JavaScriptを“柔らかい”静的型付き言語に」と「「がんばらないTypeScript」のための現実的な設定を考える ─ 4レベルの厳しさを使い分けてTypeScript疲れを克服しよう!)」を参照してください。

  5. 参照:Microsoft Developer Blogs「Announcing TypeScript 5.8 Beta

  6. この挙動をTypeScript側でも調整するのがmoduleResolutionオプションです。

  7. 参照:Microsoft Developer Blogs「Announcing TypeScript 5.0 - allowImportingTsExtensions

  8. 参照:Microsoft Developer Blogs「Announcing TypeScript 5.7