見出し画像

TypeScriptでのNode.js(ECMAScript modules (ESM))開発環境を構築してみた

はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

Node.js では既に ECMAScript modules (ESM)のサポートがされており、以下のように package.json に "type: module" を設定する事で、ESM で書いたコードをそのまま実行できるようになった(Node.js12以前は "type: module" オプションはなく、またESM自体が実験的な機能でESMを利用するには、ESM で実装したコードを CommonJS にトランスパイルするか、.mjsというファイル拡張子で明示する必要があった)。

...
	"type": "module",
...

そのため、TypeScript で Node.js のアプリケーション開発を行う際にも CommonJS ではなく、ESM にコンパイルし実行という事が可能になった。

そこで今回は、TypeScript で ESM の Node.js を開発するための環境構築をやってみたいと思う。

今回取り上げる内容は以下。

  • 必須の設定

    • TypeScript(tsconfig.json)の設定

  • 開発生産性を向上するための設定

    • Prettier の設定

    • ESLinst の設定

    • Git hooks での各種処理実行の設定(コミットメッセージチェック、依存ライブラリのライセンスチェック、ESLint の実行、コードフォーマッティング)

    • Hot Module Replacement (HMR)の設定(HMRについてはwebpackのガイドなどを参照)

必須の設定

TypeScript の設定

必要なライブラリを追加する(手元の Node.js に合わせて"@types/node"のバージョンは指定する)。

$ yarn add -D typescript @types/node@^16.18.35 @tsconfig/node16

tsconfig.json で TypeScript の開発環境(コンパイルなど)の設定を行うが、一から設定せずともtsconfig/basesというリポジトリで開発環境に合わせたベースの tsconfig が用意されているのでそれを利用する。

{
  "extends": ["@tsconfig/node16/tsconfig.json"],
  "compilerOptions": { "moduleResolution": "node16", "outDir": "./build" },
  "include": ["srv/**/*"],
  "exclude": ["node_modules", "build", "dist"]
}

今回は上記のような設定にした。中身について少し補足する。

  • extends
    @tsconfig/node16/tsconfig.jsonの設定を継承している
    もし厳格な設定にするのであれば、加えて@tsconfig/strictest/tsconfig.jsonも継承すればいい

  • compilerOptions.moduleResolution
    "@tsconfig/node16/tsconfig.json"の設定を継承すると、moduleResolution は node(node10)に設定されてしまい、コンパイル後のコードは CommonJS になってしまう
    今回は ESM の Node.js にコンパイルしたいので、この設定を上書きしている(公式のリファレンスmoduleResolutionに書かれている通り、node16 に設定する事で ECMAScript Module にコンパイルされる)

  • compilerOptions.outDir
    フロントエンドのビルド物が "./dist" に生成される事が多いのを考慮して "./build" にしている

これで TypeScript の設定は完了になる。ちなみに、継承を利用した場合、最終的な tsconfig の設定が分からなくなることがあるが、その場合には以下のように "tsc --showConfig" コマンドで設定全体を確認できる。

$ npx tsc --showConfig
{
    "compilerOptions": {
        "lib": [
            "es2021"
        ],
        "module": "node16",
        "target": "es2021",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "moduleResolution": "node16",
        "outDir": "./build"
    },
    "files": [
        "./srv/app.ts",
        "./srv/lib/helloworld.ts"
    ],
    "include": [
        "srv/**/*"
    ],
    "exclude": [
        "node_modules",
        "dist"
    ]
}

TypeScript で hello world!

続いて、簡単なコードを TypeScript で実装してコンパイル後のコードを実行してみたいと思う。コンパイルするコードは以下。

import helloworld from './lib/helloworld';

console.log(helloworld('hogehoge'));
const hello = (name: string): string => {
	return `Hello, ${name}!`;
};
export default hello;

コンパイルは "tsc" コマンドを実行するだけでいい。ただ実際にコンパイルをしようとすると、以下のようにエラーになってしまう。

どうしてエラーになってしまうかだが、エラーのメッセージにもある通り ESM においてはファイルの拡張子が必須であり、ファイルの拡張子がないためエラーになっている。

srv/app.ts:1:24 - error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './lib/helloworld.js'?
EcmaScript のインポートにおいて、「-moduleResolution」が「node16」または「nodenext」の場合、相対的なインポートパスは明示的なファイル拡張子を必要とします。./lib/helloworld.js'のことでしょうか?

TypeScript のIssue の中で議論されているされているが ESM の Node.js に TypeScript をコンパイルする場合は、TypeScript 内でも ".js" などのファイル拡張子の指定が必須になる(まだ生成されていない Node.js のファイルをインポートするのは少し変な気分ではあるが)。 というわけで、TypeScript のコードを以下のように修正する。

この修正をした後、再度 "tsc" でコンパイルを実行すると "./build" 以下に Node.js のファイルが生成されるのが確認できる。

$ tree build/
build/
├── app.js
└── lib
    └── helloworld.js

Node.js を実行してみると、問題なく実行できる事が確認できる。

$ node build/app.js
Hello, hogehoge!

※ちなみに、TypeScript の型の恩恵により、例えば引数がブランクだと以下のようにエラーが表示される。JavaScript では実際に実行しないと分からなかったり、以下のように引数のチェックロジックを実装する必要があるが、TypeScript だとそういう事はなくなる。

import { strict as assert } from 'assert';

const hello = (name) => {
	assert.ok(name, 'Name must not be empty or null or undefined');
	return `Hello, ${name}!`;
};
export default hello;

開発生産性を向上するための設定

Prettier の設定

TypeScript のプロジェクトだからこう、という特別な事はないのでここでは詳細を取り上げない。過去の記事を参照ください。

ESLint の設定

ESLint の設定を行っていく。デフォルトでは ESLint では TypeScript の構文をチェックできない(TypeScript の構文の解析方法を ESLint が知らない)ので、TypeScript 向けの parser などを設定していく。

まずは、ESLint の設定に必須になるライブラリと ESLint で TypeScript に対応するためのライブラリを追加する。

$ yarn add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

続いてプロジェクトのルートに ".eslintrc.cjs" を追加する(.cjs である理由は今回のプロジェクトが ESM の設定であるため)。

module.exports = {
	root: true,
	parser: '@typescript-eslint/parser',
	plugins: ['@typescript-eslint'],
	extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended']
}

上記の設定の詳細については公式に解説があるのでそちらを参照。

実際に ESLint が動いているか?を確かめてみると、以下のようなコードでちゃんとエラーやワーニングを検出しているので問題なく動作している事が確認できる。

※2023-06-13 時点で、最新の TypeScript(5.1.3)だと以下のような警告が出て ESLint が実行されなかった。そのため、TypeScript を 5.0.4 にダウングレードして今回の環境構築を行った("~5.0.4"にした)。

基本的な設定は上記で完了だが、推奨になっている他の設定や airbnb のルールを追加するというもの以下でやってみたいと思う。

型情報を使ったリンティングをできるようにする

"plugin:@typescript-eslint/recommended-requiring-type-checking" を追加で設定し、TypeScript の型による静的解析を実行できるようにする。導入手順はLinting with Type Informationに書かれている通り。

/* eslint-env node */
module.exports = {
  extends: [
    ...,
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
  ],
  ...
  parserOptions: {
    project: true,
    tsconfigRootDir: __dirname,
  },
  ...
};

ただ、上記のように設定した際に以下のようなエラーが発生した。

エラーで指摘されている事としては、ESLint が parserOptions.project の設定= tsconfig.json の設定に基づいて "./.eslintrc.cjs" を解析しようとしたが、tsconfig.json の include にはそんなファイル含まれてないよ、という事。今回は ESLint で ".eslintrc.cjs" などの JavaScript ファイルも解析してほしいが、TypeScript のプロジェクトでは JavaScript ファイルを扱う予定がない(allowJs: true にする予定がない)ので、以下のように別の tsconfig("tsconfig.eslint.json")を作成して、ESLint 用の設定を追加することにする。

{
	"extends": "./tsconfig.json",
	"include": ["srv/**/*.ts", "**.js", "**.cjs"]
}

後は、ESLint の設定側でこの tsconfig.eslint.json を参照するように修正すればいい。

...
	parserOptions: {
		project: './tsconfig.eslint.json',
		tsconfigRootDir: __dirname
	},
...

この設定を行った上で ESLint を実行すると以下のように問題なく解析できている事が確認できる。

※上記のエラーの内、 ".eslintrc.cjs" に関するものは以下のコードを解析した結果、出力されているもの。

※ちなみに、"plugin:@typescript-eslint/recommended-requiring-type-checking"は型のチェックによる構文解析なので、.ts ファイルに限定して問題ないルールになる。公式の例にあるようにoverridesを利用する方法もあるだろう。

Airbnb のルールを追加する

JavaScript の開発においては Airbnb のルールを入れる事でより良いコードか?のチェックをできるので、これを TypeScript のプロジェクトにおいても適用してみようと思う。

利用するライブラリとしてはeslint-config-airbnb-typescriptになる。必要なライブラリを依存に追加する("eslint-plugin-import" は "eslint-config-airbnb-base" の peerDependencies)。

$ yarn add -D eslint-config-airbnb-typescript eslint-config-airbnb-base eslint-plugin-import

後は以下のように ESLint の設定を更新するだけ。

...
	extends: [
		'eslint:recommended',
		'airbnb-base',
		'airbnb-typescript/base',
		'plugin:@typescript-eslint/recommended',
		'prettier'
	],
...

解析の結果を確認してみると、以下のように airbnb のルールも追加でチェックされるようになった事が確認できる。

※上記の ESLint の設定に "prettier" というのがあるが、これは ESLint の style ルールと Prettier のルールの競合を解消する(Prettier のルールのみにする)ための設定(eslint-config-prettier)。もしこの設定をしないと、以下のようにスタイルのルールで競合が発生しエラーが出続けてしまう。

Git hooks での各種処理実行の設定

Git hooks の設定には、simple-git-hooksを利用する。

$ yarn add -D simple-git-hooks

package.json に以下を追記して、設定したい Git hooks に合わせて実行するスクリプトを書けば OK。

{
	...
	"scripts": {
		...
		"prepare": "npx simple-git-hooks"
	},
	"simple-git-hooks": {
		"pre-commit": "...",
	}
}

以下では、コミットメッセージチェック、依存ライブラリのライセンスチェック、ESLint の実行、コードフォーマッティングのそれぞれの設定を行っていく。

コミットメッセージチェック

commitlintを利用する。

$ yarn add --dev @commitlint/{config-conventional,cli}

後は以下のようにコミットメッセージのルール設定を "commitlint.config.ts" に記載すればいい(以下のルールにはLocal Pluginsに書かれている独自のルールも追加されている)。

import type { UserConfig, Plugin } from '@commitlint/types';
import { RuleConfigSeverity } from '@commitlint/types';

const redminePlugin: Plugin = {
	rules: {
		'redmine-rule': ({ subject }) => {
			const pattern = / refs#\d+$/;

			if (!subject) return [false, `Your subject should not empty`];
			return [new RegExp(pattern).test(subject), `Your subject should contain suffix for redmine`];
		}
	}
};

const Configuration: UserConfig = {
	extends: ['@commitlint/config-conventional'],
	rules: {
		'subject-case': [
			RuleConfigSeverity.Warning,
			'never',
			['sentence-case', 'start-case', 'pascal-case', 'upper-case']
		],
		'redmine-rule': [RuleConfigSeverity.Warning, 'always']
	},
	plugins: [redminePlugin]
};

export default Configuration;

※以前、JavaScript で config の実装をしていた時は、以下のような Local Plugins の実装をしていたが、subject が null になる場合、test()メソッドの引数が要求する string にマッチしないものが渡ってしまう事を防げない実装になっていた。しかし、TypeScript になり型のチェックが入る事で null が絶対に渡らないように修正された。こうした事が TypeScript の恩恵と言えるだろう。

			rules: {
				'redmine-rule': ({ subject }) => {
					const pattern = / refs#\d+$/;
					return [
						new RegExp(pattern).test(subject), // <- subjectがnullがあり得る
						`Your subject should contain suffix for redmine`
					];
				}
			}

ちなみに、if(!subject)の分岐を実装しないと、commitlint のコマンドを実行した際に ts-node でコンパイルされ、その際にエラーになる。

トラブルシューティングのログ ①

commitlint 設定を行うにあたり、以下のようなエラー("TypeError: value.replace is not a function")が発生した。

$ git commit -m "chore: commitlintの設定を追加"
/home/study/workspace/ts-node-oidc/node_modules/@commitlint/cli/lib/cli.js:123
        throw err;
        ^

TypeError: value.replace is not a function
    at normalizeSlashes (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist/util.js:62:18)
    at Object.getExtendsConfigPath (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist/ts-internals.js:24:54)
    at readConfig (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist/configuration.js:127:64)
    at findAndReadConfig (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist/configuration.js:50:84)
    at create (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist/index.js:146:69)
    at register (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist/index.js:127:19)
    at TypeScriptLoader (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/cosmiconfig-typescript-loader/dist/cjs/index.js:52:54)
    at loadConfig (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/lib/utils/load-config.js:12:75)
    at load (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/lib/load.js:19:55)
    at main (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/cli/lib/cli.js:194:45)

エラーの発生している ts-node のコードをデバックした所、以下のコードでエラーになっている事、typescript の v5 からサポートされた tsconfig の extends を配列で複数設定できるようなった事が原因である事が分かった。

{
	"extends": ["@tsconfig/node16/tsconfig.json", "@tsconfig/strictest/tsconfig.json"],
    ...
}
// node_modules/@commitlint/load/node_modules/ts-node/dist/util.js:63:18
function normalizeSlashes(value) {
    return value.replace(backslashRegExp, directorySeparator); // <- ここでエラー
}

value の値を console.log で確認した所、以下のように extends に指定していた配列だった。

解決策としては一旦、extends の配列指定を辞めて、extends で設定される内容を tsconfig に手動で記載する事にした。ちなみに根本解決については、ts-node の方で既に修正の PR がマージされているようだが、リリースはまだされていない模様。

トラブルシューティングのログ ②

commitlint を実行した所、以下のようなエラー("Error [ERR_REQUIRE_ESM]: Must use import to load ES Module")が発生した。

$ git commit -m "chore: commitlintの設定を追加"
/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist-raw/node-internal-errors.js:46
  const err = new Error(getErrRequireEsmMessage(filename, parentPath, packageJsonPath))
              ^
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /home/study/workspace/ts-node-oidc/commitlint.config.ts
require() of ES modules is not supported.
require() of /home/study/workspace/ts-node-oidc/commitlint.config.ts from /home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/cosmiconfig-typescript-loader/dist/cjs/index.js is an ES module file as it is a .ts file whose nearest parent package.json contains "type": "module" which defines all .ts files in that package scope as ES modules.
Instead change the requiring code to use import(), or remove "type": "module" from /home/study/workspace/ts-node-oidc/package.json.

    at createErrRequireEsm (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist-raw/node-internal-errors.js:46:15)
    at assertScriptCanLoadAsCJSImpl (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/dist-raw/node-internal-modules-cjs-loader.js:584:11)
    at Object.require.extensions.<computed> [as .ts] (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/ts-node/src/index.ts:1610:5)
    at Module.load (node:internal/modules/cjs/loader:1074:32)
    at Function.Module._load (node:internal/modules/cjs/loader:909:12)
    at Module.require (node:internal/modules/cjs/loader:1098:19)
    at require (node:internal/modules/cjs/helpers:108:18)
    at loader (/home/study/workspace/ts-node-oidc/node_modules/@commitlint/load/node_modules/cosmiconfig-typescript-loader/dist/cjs/index.js:59:22)
    at Explorer.loadFileContent (/home/study/workspace/ts-node-oidc/node_modules/cosmiconfig/src/Explorer.ts:81:20)
    at Explorer.createCosmiconfigResult (/home/study/workspace/ts-node-oidc/node_modules/cosmiconfig/src/Explorer.ts:93:36) {
  name: 'TypeScriptCompileError',
  filepath: '/home/study/workspace/ts-node-oidc/commitlint.config.ts'
}

エラー自体は ESM の Node.js では良く見慣れたもので、ESM のプロジェクトにおいて.js の JavaScript で CommonJS の構文(require())が用いられている事で発生しているエラー。commitlint は ts-node で TypeScript をコンパイルして commitlint の処理を実行しているが、コンパイルは ts-node を利用しており、そのコンパイルされた Node.js に問題があるためエラーになっている。

解決策としては、TypeScript の公式サイトECMAScript Modules in Node.js New File Extensionsに書かれている方法、ファイルの拡張子を ".cts" にするというものになる。これによりコンパイル後の JavaScript のファイル拡張子は ".cjs" になるので、ESM のプロジェクト内でも問題なく実行できるようになる。ちなみに、この辺りの話に関しては、commitlint のissuePRが既にある。

"commitlint.config.cts" にリネームした後、git commit を行うと、問題なく commitlint が実行される事が確認できる。

依存ライブラリのライセンスチェック

設定内容は過去の記事で取り上げた内容と全く同じになる。詳細は過去の記事を参照ください。

ESLint の実行

「依存ライブラリのライセンスチェック」で lint-staged を導入しているので、lint-staged の設定を少し拡張するだけ。

"simple-git-hooks": {
		"pre-commit": "npx lint-staged --verbose",
		...
	},
	"lint-staged": {
		"**/*.{cjs,mjs,js,ts,cts,mts}": [
			"eslint"
		],
		...
	},

設定内容としては、ackage.json の scripts に eslint のコマンドを定義し、lint-staged で ESLint を実行したい対象ファイルを設定するという事。これで以下のように対象ファイルの変更がコミットされれば、チェックが走るようになる。

コードフォーマッティング

この設定も以下のように lint-staged の設定を少し拡張するだけ。

"lint-staged": {
		"*": "prettier --ignore-unknown --write",
		...
	},

Prettier のフォーマッティング対象外のファイル形式だった場合は無視するための設定として "--ignore-unknown" フラグを追加している。

Hot Module Replacement (HMR)の設定

TypeScript で開発をする場合、コンパイルを行い Node.js に変換しないと実行はできない。ただ、都度 "tsc" コマンドを実行して、コンパイルが完了したら "node build/app.js" で Node.js を実行する、のような方法で開発を行うと開発体験として良くない。そこで HMR の仕組みを設定する事で、TypeScript のファイルを変更して保存すれば、最新のコードが実行されるという状態を実現してみたいと思う。

ts-nodeを利用する場合

ts-node はあたかもコンパイルを行う事なく TypeScript のコードを実行するかのように開発ができるライブラリ。具遺体的には、以下のように TypeScript をそのまま実行しているような感覚で利用できる。

$ npx ts-node --esm srv/app.ts
Hello, hogehoge!

この ts-node とnodemonを利用する事で、簡単に HMR が実現できる。まず、それぞれを依存に追加する。

$ yarn add -D ts-node nodemon

後は package.json の scripts に以下のようなスクリプトを設定し実行するだけ(フラグ "--esm" についてはNative ECMAScript modulesを参照)。

後は package.json の scripts に以下のようなスクリプトを設定し実行するだけ(フラグ "--esm" についてはNative ECMAScript modulesを参照)。

"scripts": {
		"nodemon:exec": "nodemon --watch 'srv/**/*.ts' --esm srv/app.ts",
        ...
}

これで以下の動画のように HMR が実現できる(今回は Express サーバーなどではなく、実行して終わりのコードではあるが、サーバーでも今回の設定と同じになる)。

公式のDefault executablesに書かれている通り、nodemon は "execMap" の設定によりファイルの拡張子をみて実行するプログラムを自動で選択してくれる機能がある。今回は "srv/app.ts" で ".ts" なので、defaults.jsの設定に基づき、 ts-node でファイルが実行されていた。

※ただし、Native ECMAScript modulesに以下のような記載がある通り、ts-node をプロダクション環境ように利用する(tsc コマンドでコンパイルした Node.js コードを実行するのでは、ts-node で Node.js を実行する)のは非推奨になっているので注意。

Node's ESM loader hooks are experimental and subject to change. ts-node's ESM support is as stable as possible, but it relies on APIs which node can and will break in new versions of node. Thus it is not recommended for production. ts-node の ESM サポートは可能な限り安定していますが、node の新しいバージョンで壊れる可能性のある API に依存しています。そのため、実稼働にはお勧めできません。

tsc でコンパイル後、Node.js を実行する

こちらの方法は ts-node を利用せず、"tsc" コマンドに "--watch" フラグを追加で渡し、ファイル変更で都度コンパイルを行い、そのコンパイル後のコードを実行する、という方法。コードの実行を nodemon で行う事で HMR が実現できる(tsc コマンドのオプションについては公式のCompiler Optionsを参照)。

"scripts": {
		"tsc:watch": "tsc --watch",
		"nodemon": "nodemon build/app.js",
        ...
}

これで以下の動画のように HMR が実現できる。

※ts-node を利用するパターンと利用しないパターンでは、ts-node を利用せず "tsc" でコンパイル後に Node.js を実行する方法の方が、本番環境での Node.js 実行と同じになるので、開発環境と本番環境の差異をなくす意味ではいい方法と言えそう。

まとめとして

今回は、TypeScript での ESM の Node.js の開発環境を構築するという事をやってみた。

TypeScript で Node.js を開発する場合、今までであれば CommomJS にコンパイルするという事が多いと思う。ただ、いくつかのライブラリでは既に「ESM にしか対応しません、CommomJS は未対応です」というPure ESM packageなる考え方も出てきており、ESM の Node.js のプロジェクトも増えてくるのではないかと思っている。そのため、今後は今回やってみた ESM の Node.js にコンパイルする事を念頭に置いた各種セットアップを行う場面も多くなるのではないかと感じた。

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
認証認可(SHIFTアカウント)や課金決済のプラットフォーム開発に従事。リードエンジニア。
経歴としては、SaaS ERPパッケージベンダーにて開発を2年経験。
SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。その後現在のプラットフォーム開発に参画。