Denoでも動くようにNode.jsプロダクト開発でやっておくとよいこと

:::note info
本記事は、[mediba Advent Calendar 2024](https://qiita.com/advent-calendar/2024/mediba) 20日目の記事です。
:::

:::note warn
本記事の内容は個人的な見解であり、会社を代表するものではありません。
:::

## はじめに

今年はNode.jsの競合である[Deno](https://deno.com/)や[Bun](https://bun.sh/)も頻繁にアップデートされ、より実用的になってきた年だったように思います。

特にDenoは、先日[Deno 2がリリースされ](https://deno.com/blog/v2.0)、package.jsonのサポートなどNode.jsプロジェクトとの互換性も大幅に強化されました。
Node.jsにはないTypeScriptネイティブサポートやフォーマッタやリンターの標準搭載などの強みもあり、
将来的には業務でもNode.jsからDenoへ移行する選択肢が出てくる場合もあるのではないかと考えています。

とはいえ、すべてのNode.js用のコードがそのままDenoで動作するわけではありません。

今回は個人的に、できるだけDeno環境でも動作するように、Node.js開発の時点でやっておくとよいことを模索してみたので、５点ほど書きたいと思います。

## やっておくとよいこと

### 1. ES Modules(ESM)にする

DenoではESMを推奨しており、CommonJS(CJS)も動かなくはないですが、ひと手間必要です。
Node.jsもデフォルトではCJSですが、ESMをサポートしています。ESMを採用しましょう。

Node.jsではpackage.json に以下の記述を加えることでESMとして動作するようになります。

```diff:package.json
  {
    "name": "project-name",
    "version": "1.0.0",
+   "type": "module",
    ...
  }
```

ESMにするとCJSの場合に使えていた`require()`や`__filename`,`__dirname`が使えなくなりますが、必要な場合は同様の変数を簡単に定義できます

```ts
import { createRequire } from "node:module";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
```

### 2. TypeScriptでコーディングする

DenoはTypeScriptを標準でサポートしています。
Node.js開発においてもTypeScriptでコーディングし、JSにトランスパイルするのが主流になっているかと思います。

Node.jsはまだ標準ではTypeScriptコードを実行できないので、[esbuild](https://github.com/evanw/esbuild)や[tsx](https://tsx.is/)などのツールを入れておきます。

```diff:shell
npm install -D @types/node esbuild tsx typescript
```

tsconfig.jsonでは、
ESMの機能を有効化するために`“module”:“es2022”`にします。
`strict` などの厳格なルールを有効化して開発をおこなっておくとDenoなどに移行してもトラブルが起きにくいでしょう。

以下は設定した tsconfig.json の一例です。

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "module": "es2022",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "allowJs": true,
    "checkJs": false,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "allowUnusedLabels": false,
    "allowUnreachableCode": false,
    "skipLibCheck": true
  }
}
```

※Denoはtsconfig.jsonを参照しない点は注意が必要です。Denoのデフォルトと異なるcompilerOptionsを設定したい場合は、deno.jsonに記述する必要があります。

### 3. import文の拡張子は.tsにする

ESMでは、import文のパスに拡張子を含めたファイル名まで記述する必要があります(CJSのように拡張子を省略できません)。

Node.js+TypeScriptコンパイラ(tsc)開発の場合は、初期設定ではトランスパイル後のファイル名(拡張子.js)で記述しなければなりません。

しかし、そうするとDenoでは動作しません。ソースコードのファイル名(拡張子.ts)で記述する必要があります。

```diff
- import { hoge } from "./hoge.js";
+ import { hoge } from "./hoge.ts";
```

**import文を拡張子.tsで記述を可能にする設定**

TypeScriptのcompilerOptionsで`allowImportingTsExtensions`を`true`にすることで、
Node.js開発でも拡張子.tsでimportパスを記述可能になります。

ただし、`allowImportingTsExtensions`を `true` にする場合、`noEmit`また`emitDeclarationOnly` を有効にする必要があるので、トランスパイルはesbuildなどで行うことになります。

```diff:tsconfig.json
  {
    "compilerOptions": {
      "target": "es2022",
      "lib": ["es2022"],
      "module": "es2022",
      "moduleResolution": "bundler",
+     "allowImportingTsExtensions": true,
+     "noEmit": true,
      ...
    }
  }
```

※先日リリースされた TypeScript 5.7 で `rewriteRelativeImportExtensions` というオプションが追加され、tscでもトランスパイルさせることができるようになりましたが、このオプションにもいくつかの制約があるようです。

### 4. Node.js組み込みパッケージのimportは `node:` プレフィックスを付ける

```diff
- import { resolve } from "path";
+ import { resolve } from "node:path";
```

Node.jsでは、組み込みパッケージはパッケージ名のみでimportできますが、Denoではエラーになります。
Node.js組み込みパッケージをimportする際は、パッケージ名に `node:`プレフィクスを付けます。
Node.jsでも引き続き問題なく動作します。

:::note warn
すべてのNode.js組み込みパッケージが利用できるわけではありません、Denoに実装されているNode.js組み込みAPIの互換パッケージは[こちら](https://docs.deno.com/api/node/)をご参照ください。
:::

### 5. Node.jsグローバルオブジェクトは明示的にimportする

```diff
+ import { Buffer } from "node:buffer";
+ import process from "node:process";

  const base64 = Buffer.from("Hello,World!").toString("base64");
  
  process.exit(0);
```

`process`パッケージや`Buffer`オブジェクトなど、Node.jsではグローバルオブジェクトとしてimport不要で使用できますが、Denoではグローバルではないため利用できません。(`console`などDenoでもグローバルに存在するものもあります)
明示的にimportすることでDenoでも動作します。

# おわりに

最近はNode.js本体にも、watch実行・.envファイルサポート・テストランナーが組み込まれたり、
TypeScriptコードを直接実行できるようなオプションや、シングルバイナリにビルドする方法が実験されていたり、
開発環境は日進月歩の勢いで変わっています。
これからも日々キャッチアップを続け、社内外にも共有していければと思っています。

# 参考資料

- [Deno Runtime Manual](https://docs.deno.com/runtime/)
- [Deno API Reference | Node APIs](https://docs.deno.com/api/node/)