tsのフロントビルド環境からdenoランタイム用のコードベースを呼び出して共通化してみる試み
フロントtscビルド環境のコードベース→deno配下コードベース、の参照ができた
逆はやってない
ただどのみち細工的なところがあるので、なかなか汚い感じではある
コード:
モチベ:
バックエンドでdeno、フロントでtscのビルド環境を組んでいるなら、tsのコードベースが共有できるはず。一部の型、スキーマ定義、シリアライザ、バリデーション、細かなユーティリティ等を共有できれば便利。Single Source of Truthでもある。
すこし話が脱線してしまうのだが、コードベース共有するのは良いことだけではない。特にモデルの型定義などはフロントで取り回す型と、バックエンドで取り回す型は独立にしておくことが概念的により正しいと言えることが多い。例えば、フォームの入力データではフォームのUI要素の構造を反映したモデル構造にするべきである。単純な例として簡易的な日付の入力フォームを作るとすると、UIとしては年,月,日の3つのテキストとして状態を保持し、それぞれでバリデーションを付けたりする。対してデータとしてはDate型として保持するのが自然になる。さらにこれらがAPIを通過するときにはISO8601のテキスト表現になって送信され、再びサーバー側でDate型として取り回していく。同じテキスト型ではあるがフロントのUIにおけるスキーマと、APIの中を通過する中間表現としてのスキーマではまったく考え方が違ってくる。故に、型を共有してしまうと逆に不自然な点が出てきてしまうことが往々にしてある。
とはいえ、大きくコードベースを共有することは少ないが、小さく依存の少ないコードベースを共有することはしばしばある。今回の目的はそれに即していて、複雑な依存解決を行ったりすることは考慮されていない。依存しているパッケージ次第では動かない可能性も十分にある。少なくとも今回のサンプルではワークしているというだけに過ぎないが、いったんひとつの現実的なケースでは問題なく動いている。
tsコードベースを相互参照する上での障壁:
今回はdenoと、parcel経由でtscを利用している。そして達成するべき点は共有しているコードベースにおいて、denoランタイムとtscでの型情報,実行結果に差異がないこと。いったんtsc→denoの依存のみを許容して、考えなければならない点を整理すると以下の3つになる。
tsのコンパイルオプション一致
tscでの型解決で正しくモジュールを解決できる
parcelでのバンドル時に正しくモジュール解決できる
1つ目。tsのコンパイルオプションは特に困ることはない。tsconfig.jsonとdeno.jsonを一致させれば特に問題は起きない。自動生成して同一のconfigを参照できるようにしたら、乖離を抑制することができそう。
2つ目。tscでのモジュール解決問題。これはさらに2つの問題に切り分けられる。
2つ目-1。ソースのモジュール解決
一つは相対パスをどう解決するか。これはpathsを設定してエイリアスで記述すれば体験が良い。
code:tsconfig.json
{
"compilerOptions": {
"paths": {
}
}
}
一つは、denoのソースコード間のimport {} from "./helper.ts"のような.tsの拡張子をどのように解決するか。これがだいぶむずい。
denoは.tsをつけることになっている。これは通常のtscでは解決できない、と思っていたのだが、ts@5.0.0の"moduleResolution": "bundler" を使ってシンプルに解決できた!
あくまでもtscは型情報を使った検証と削除のみを行い、一貫してランタイムに関与することはない。そのためesmでimportするファイルの拡張子を省略せずに記述できる仕様があるが、tscはその対象のファイルが.tsであったとしても.jsとして記述しなければ解釈してくれなかった。ところが、今回のケースではそれは困ってしまう。denoでは.tsが強制されるので省略して記述することはできない。となるとtsc側の解釈をどうにかするしかなかったのだ。
このbundlerのオプションでは .ts での記述を解釈できるようになる。しかしその先の依存解決はランタイムなりバンドラなりが責任持ってやらないければならない。それが3つ目の問題になってくる。
それはそれとして、ほしいと思っていたものがすごい良いタイミングで来てありがた過ぎた。5.0.0-dev.20230112 で検証して、うまく動作している。
2つ目-2。npmなど外部の依存モジュール解決
これは外部モジュールのimportの記述方法がdenoとtscとで異なるために起きる問題。
code:deno.ts
code:tsc.ts
import { z } from 'zod'
denoでは、上記のように直接URLを記述することができる。tscはこれを解釈できない。
これは逆にdeno側を細工することで解決した。
code:import_map.json
{
"imports": {
}
}
code:deno.ts
import { z } from 'zod'
import_map.json を書いて、あたかもnode_modulesから拾ってきているかのようにエイリアスを張るのである。そうするとtsc視点ではnode_modulesへの参照として解釈され、deno視点ではimport_mapによってエイリアスが解決されURLを拾ってくる。
ちょっとおもろい感じなのだが、普通にぶっ壊れそうである。でも思いついた時ちょっと気持ちよくなった。
ぶっ壊れそうポイントは、モジュールの解決先が違うこと。denoではURLにしたがって解決され、tscではpackage.jsonに記述されnode_modulesに展開されたものを解決する。バージョンが食い違わないように注意しなければならない。同一のファイルを参照させる単純な方法は思いつかないので、こればっかりはCIでlockファイルの整合性をチェックしないとほんとにぶっ壊れそうである。
3つ目。parcelでのバンドル時のモジュール解決
これは↑で頑張ったのである程度楽になっている。モジュールの解決の問題はお陰様で解決済みなので、ソースの解決のみ対応する。
ただこれはよくあるやつ。tsconfigのpathsに設定したエイリアスと同様の解決をバンドル時にも行えば問題ない。
code:package.json
{
"alias": {
"@shared/*": "./netlify/edge-functions/_shared/$1"
}
}
parcelはpackage.jsonのaliasを読みに行ってくれるので、これだけで完了。
あとは常にtsconfig.jsonと整合しているかを気にすればよい。ツールチェインがいくつかあるので既にあるもので解決できそう。
あと、vscodeでのエディタ支援などは特に困ることはなかった。denoのエントリポイント配下ではdenoのextensionが利用され、それ以外では通常のtsのextensionが利用される。型定義へのジャンプなども問題なく利用できるが、共有しているソースコードはdeno側にあるのでFind All Referencesでは当たり前ではあるがスコープ外は表示されない。
リアリスティックなアプリケーションでの例を以下から見ることができるので、ぜひ面白さを感じてくれたらと思います。