ISUCON13で遭遇したTypeScriptとES Modulesにまつわるエラーとその解決

この記事は、株式会社ビットキー Developer Advent Calendar 2023 19日目の記事です。

https://qiita.com/advent-calendar/2023/bitkey

## tl;dr

ISUCON13で実際に遭遇した問題を題材に、tsconfig.jsonの`lib`に設定を追加してECMA Scriptの新しい機能を追加する方法と、Node.jsをNative ES Modulesとして動かすために、必要な設定変更と修正が必要なアプリケーションコードについてまとめました。

## あらすじ

先月、ISUCON13に同僚とチームを組んで参加して来ました。実装言語は日々の業務で利用しているNode.jsを選択したのですが、構成の変更中にTypeScriptのコンパイルとES Modulesに関連した問題が発生し、苦戦しつつも解決できたので、備忘録的に共有します。

なお、この記事ではISUCON自体の解説はしませんので、ISUCONが何かを知りたい方は[ISUCON公式ホームページ](https://isucon.net/)を参照してください。

## ISUCON13 Node.js実装の解説

### ISUCON13のNode.js実装はTypeScriptを事前にコンパイル[^1]しない！

ISUCON13のNode.jsは、tscによるコンパイルを行わず、tsxによる実行時の変換でサーバーが実行されていました。

https://github.com/isucon/isucon13/tree/main/webapp/node

(参考)サーバー上でNode.jsのプロセスをps auxfオプションで表示したもの

```terminal
isucon      1331  0.1  1.5 1095596 57520 ?       Ssl  01:20   0:00 npm run start
isucon      1346  0.0  0.0   2888  1664 ?        S    01:20   0:00  \_ sh -c tsx watch src/main.ts
isucon      1347  0.1  1.3 11476996 52428 ?      Sl   01:20   0:00      \_ node /home/isucon/webapp/node/node_modules/.bin/tsx watch src/main.ts
isucon      1358  0.3  1.9 65146028 72836 ?      Sl   01:20   0:01          \_ /home/isucon/local/node/bin/node --require /home/isucon/webapp/node/node_modules/tsx/dist/preflight.cjs --import file:///home/isucon/webapp/node/node_modules/tsx/dist/loader.mjs src/main.ts
isucon      1378  0.0  0.3 721044 12168 ?        Sl   01:20   0:00              \_ /home/isucon/webapp/node/node_modules/esbuild/lib/downloaded-@esbuild-linux-x64-esbuild --service=0.18.20 --ping
```

### JavaScriptにコンパイルするぞ！

一般的には事前にJavaScriptにコンパイルした方が、サーバーのパフォーマンスが良くなるので、初手でTypeScriptのコードをJavaScriptにコンパイルする修正を入れることにしました。[^2]


:::note info
以下、実行環境は下記のバージョンを前提とします。
Node.js 20.9.0
TypeScript 5.3.2
:::

## 遭遇したエラーと解決方法

### エラーその１：error TS2551: Property 'toReversed' does not exist on type '{ username: string; score: number; }[]'. Did you mean 'reverse'?

TypeScriptでコンパイルするためにおもむろにtsconfig.jsonを開きます。

```json
{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "compilerOptions": {
    "types": ["bun-types"]
  }
}
```

記述はこれだけですが、`"extends": "@tsconfig/recommended/tsconfig.json”`は以下の設定を取り込みます。


https://github.com/tsconfig/bases/blob/main/bases/recommended.json


extendsを展開した上で、`"types": ["bun-types"]`はbunを使わないため削除、出力先の指定に`"outDir": "dist/"`を追加してtscを実行します。

```diff_json
{
  "compilerOptions": {
    "target": "es2015",
+   "outDir": "dist/",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
-   "types": ["bun-types"]
  }
}
```

```terminal
$ npx tsc
src/handlers/stats-handler.ts:83:31 - error TS2551: Property 'toReversed' does not exist on type '{ username: string; score: number; }[]'. Did you mean 'reverse'?

83       for (const r of ranking.toReversed()) {
                                 ~~~~~~~~~~

  node_modules/typescript/lib/lib.es5.d.ts:1365:5
    1365     reverse(): T[];
             ~~~~~~~~~~~~~~~
    'reverse' is declared here.

src/handlers/stats-handler.ts:244:31 - error TS2551: Property 'toReversed' does not exist on type '{ livestreamId: number; title: string; score: number; }[]'. Did you mean 'reverse'?

244       for (const r of ranking.toReversed()) {
                                  ~~~~~~~~~~

  node_modules/typescript/lib/lib.es5.d.ts:1365:5
    1365     reverse(): T[];
             ~~~~~~~~~~~~~~~
    'reverse' is declared here.


Found 2 errors in the same file, starting at: src/handlers/stats-handler.ts:83
```

:::note alert
error TS2551: Property 'toReversed' does not exist on type '{ username: string; score: number; }[]'. Did you mean 'reverse'?
:::

`Property 'toReversed' does not exist on type`のエラーが発生しました。
`toReserved`はECMAScript Proposalで[2023年1月にStage 4に到達した](https://github.com/tc39/proposals/commit/ad4df8435f27f39eda26db3b940ae151980c8015)、[配列要素を非破壊的に変更するメソッド群](https://github.com/tc39/proposal-change-array-by-copy)のひとつです。

コンパイラのターゲットで指定したECMA Scriptのバージョンにまだ追加されていない新しい機能を利用したい場合、tsconfig.jsonの`lib`に追加の設定を書くことができます。

https://www.typescriptlang.org/tsconfig#lib

```diff_json
{
  "compilerOptions": {
    "target": "es2015",
+   "lib": ["ES2023.Array"],
    "outDir": "dist/",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

これでJavaScriptにコンパイルできました。

```shell
# エラーにならない場合はコンソールに何も出力されない
$ npx tsc
```

### エラーその2：Error [ERR_REQUIRE_ESM]

無事にコンパイルできたので、サーバーを実行します。

```bash
# 実際のサーバー起動コマンドはsystemdで管理されていますが説明のため簡略化します
$ node dist/main.js
/home/isucon/webapp/node/node_modules/hono-sessions/script/deps.js:27
var async_1 = require("nanoid/async");
              ^

Error [ERR_REQUIRE_ESM]: require() of ES Module /home/isucon/webapp/node/node_modules/nanoid/async/index.js from /home/isucon/webapp/node/node_modules/hono-sessions/script/deps.js not supported.
Instead change the require of index.js in /home/isucon/webapp/node/node_modules/hono-sessions/script/deps.js to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (/home/isucon/webapp/node/node_modules/hono-sessions/script/deps.js:27:15) {
  code: 'ERR_REQUIRE_ESM'
}

Node.js v20.9.0
```

:::note alert
Error [ERR_REQUIRE_ESM]
:::

これは依存しているnpmパッケージの内、hono-sessionsはNative ES Modulesのみに対応しているため、デフォルトでNode.jsでは実行できません。

Node.jsのNative ES Modules対応の問題や背景はすでに様々な記事で解説がなされているので、記事末にまとめるにとどめ、ここでは深入りしません。

少なくともこの状態ではサーバーが実行できないため、サーバーもNative ES Modulesで実行されるように修正していきます。

まずは、package.jsonに`”type”: “modules”`を追加します。
これによって、拡張子がmts、mjsでなくてもES Modulesとして解釈されるようになります。

```diff_json
{
  "name": "isupipe",
  // 中略
+ "type": "module"
}
```

次に、tsconfig.jsonの`module`を`Node16`に変更します。
package.jsonが`"type": "module"`の場合、ES Modulesとして解釈します。

```diff_json
{
  "compilerOptions": {
    "target": "es2015",
    "lib": ["ES2023.Array"],
    "outDir": "dist/",
+   "module": "Node16",
-   "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

この状態でコンパイルするとimportの構文エラーが発生します。
ES Modulesはimport宣言で拡張子をつける必要があります。

```bash
$ npx tsc
src/handlers/livecomment-handler.ts:3:33 - error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../types/application.js'?

3 import { HonoEnvironment } from '../types/application'

# 以下、他にもたくさん
```

修正例

```diff_javascript
import { Context } from 'hono'
import { RowDataPacket, ResultSetHeader } from 'mysql2/promise'
+ import { HonoEnvironment } from '../types/application.js'
- import { HonoEnvironment } from '../types/application'
// 後略
```

また、ES Modulesでは`__dirname`, `__filename`が[そのままでは利用できないため](https://nodejs.org/docs/latest-v20.x/api/esm.html#no-__filename-or-__dirname)次の行を追加します。

```diff_javascript
+ import { fileURLToPath } from "url";
+ import path from "path";
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
```

これでNative ES Modulesでサーバーを実行できるようになりました！

## まとめ

ISUCON13で実際に遭遇した問題を題材に、TypeScriptでECMA Scriptの新しい機能を追加する方法とNode.jsをNative ES Modulesで動かす方法を解説しました。

日常の業務では、何となくでしか理解していなかった、tsconfig.jsonについて振り返る良い機会になったと同時に、競技中は素早く対応できなかったため実力不足を思い知らされました。[^3]

20日は [@takuuuuuuu777](https://qiita.com/takuuuuuuu777) が担当します。お楽しみに！


## Appendix：Node.jsのモジュールシステムの複雑さを解説する偉大な先人の記事

https://zenn.dev/qnighy/articles/19603f11d5f264

https://quramy.medium.com/typescript-4-7-%E3%81%A8-native-node-js-esm-189753a19ba8

https://blog.s2n.tech/articles/dont-use-moduleresolution-node

https://zenn.dev/uhyo/articles/typescript-module-option

https://qiita.com/uhyo/items/c33489155e1817479948


[^1]: TypeScriptをJavaScriptに変換することは厳密にはトランスパイルですがここではコンパイルで統一します

[^2]: 事前にコンパイルした結果、予想は外れてスコアは落ちました。どうして

[^3]: ISUCONは日々の業務を8時間に濃縮したコンテストとも言えますが、Node.jsの環境起因エラーまで体感できるとは思ってもいませんでした