🦕

Deno/Node を vscode ワークスペースで共存させたい

2023/09/11に公開

次の記事の 2023 年版です。

https://speakerdeck.com/mizchi/deno-node-liang-dao

tl;dr

次のコードを "deno.enable": true にしない(Node環境のまま)Node+Deno両方の型チェックが通る vscode 環境にする。

deno/main.ts
// .ts 拡張子を許可
import { dep } from "./dep.ts";
// node_modules から npm module を解決
import ts from "typescript";
// deno/node 共用で Deno に型をつける
const Deno: typeof import("@deno/shim-deno").Deno =
  (globalThis as any).Deno ?? (await import("@deno/shim-deno")).Deno;

const source = ts.createSourceFile("test.ts",`const a = 1;`,ts.ScriptTarget.ES2015,true);
const json = await Deno.readTextFile("./tsconfig.json");

やりたいこと

こういうプロジェクトを組みたいとします。

src/
  index.ts # node のコード
deno/ # deno
  mod.ts # deno のコード
deno.jsonc
package.json # node 用の設定

node と deno が両方が共存しているプロジェクトです。個人的な使い分けとして、deno は依存解決や CLI やタスクを書くのに設定が少なく便利で、 node は実績あるツールが多いので、両方使いたいことが多いです。

ですが、実際同一リポジトリ内で node/deno を共存させようとすると、vscode で "deno.enable": true を設定した際にモジュール解決やその他の挙動が deno 向けになり、 node プロジェクトと deno プロジェクトが排他になっている、という問題がありました。

今回は、これを、typescript を Deno っぽくする設定と、Deno の package.json 互換機能でどうにかします。

node の TypeScript のモジュール解決を Deno っぽくする

TypeScript@5 前後で "allowImportingTsExtensions": truemoduleResolution: "Bundler" という設定が追加されました。

https://www.typescriptlang.org/ja/tsconfig#allowImportingTsExtensions

tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "ESNext",
    "allowImportingTsExtensions": true,
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

これによって、 import foo from "./foo.ts" という TypeScript の依存解決が許容されるようになりました。

(allowImportingTsExtensions: true を使う際は noEmit: true が必須になります。)

Deno の package.json 対応を使いたい

https://deno.land/manual@v1.36.4/node/package_json

  • deno task ... で package.json の npm scripts を実行できる
  • Deno から package.jsondependencies devDependencies で定義されたモジュールを解決できる

今回大事なのは package.json の解決の方で、deno から node と同じルールで npm modules を解決できるようになります。これを通ることで、 vscode 上の TS LSP でも deno と node で同じパス解決をするように見えます。

typescript を呼んでみる例です。

$ npm init -y
$ npm install typescript -D
deno/mod.ts
import ts from "typescript";
const source = ts.createSourceFile(
  "test.ts",
  `
  const a = 1;
  const b = 2;
  console.log(a + b);
`,
  ts.ScriptTarget.ES2020,
  true,
);
console.log(source.statements[0].getText(source));

このスクリプトは、deno.enable が true でも false でも型が通ってるように見えます。

$ deno check deno/mod.ts # 確認のため
$ deno run --allow-read deno/mod.ts
const a = 1;

deno cache 時に package.json から deno.lock ファイルが生成され、 deno run ではこの lock から依存を解決します。(cacheの実体はマシン内のどこかに永続化されてるはず)

Deno のグローバルオブジェクトに型を付ける

これだけでも EcmaScript としては十分なんですが、おそらく次に遭遇する問題は、グローバル変数の Deno は node 環境だと存在しない問題です

自分は @deno/shim-deno を使って解決しました。これは本来 dnt という deno->node に変換する時のポリフィルです。

https://github.com/denoland/node_shims/tree/main/packages/shim-deno

globalThis.Deno が存在しない場合、その shim を読み込むようなコードにしておきます。

deno/mod.ts
import { dep } from "./dep.mts";
import ts from "typescript";
// import { Deno } from "@deno/shim-deno"; // deno => node shim => deno という感じになるが一応動く
const Deno: typeof import("@deno/shim-deno").Deno =
  (globalThis as any).Deno ?? (await import("@deno/shim-deno")).Deno;

const json = await Deno.readTextFile("./tsconfig.json");
console.log(json);

これだったら型だけ Shim から解決して、deno run の実行は TypeScript になります。

残る問題点

これで、 Node 環境で Deno のグローバル変数があり、 .ts を解決するようなプロジェクト設定になりました。ただ、注意点として、あくまで似たような設定になって vscode の型のエラーがでなくなってるだけです。

deno/ 以下を本物の vscode-deno 環境として読み直すために、deno/.vscode/settings.json も置いておきます。

{
  "deno.enable": true
}

これで $ code deno/ --add なんかで workspace root として読み込むと、そのワークスペースでは deno としてあつかわれます。

また、 allowImportingTsExtensions は import specifier の *.ts を許可するだけで、この拡張子の記述を強制する lint ルール等が現状ありません。なので、拡張子省略をしていないか確認するために、 deno check で実行してみるしかないです。まあ実行時に気づけるので問題ないですが...

感想

同じことをしようとした 1年前と比べると、色々と便利になってるように感じました。

ただ、ビルド不要で無設定で使えてポータブルなスクリプトを書ける、という deno の最初の強みが、 deno 自体のオプションが増えてしまったことで失われているように感じていて、ちょっと寂しく思ったのも事実です。

Discussion