ラップした内部依存(CommandTransport)を外から完全に隠蔽する
TL;DR
- まず「隠したい外側」がどこかを決める。コンポジションルート(依存を組み立てる唯一の場所)が内部依存を知るのは正しい。
- コンポジションルートからも消したいなら、組み立てごとファサード(
TauriPorts.create()/createNull())に押し込める(Parameterless Instantiation)。createNullの設定は port の言葉で受け取り、内部で IPC のレスポンスに分解する(Fake It Once You Make It)。 - 物理的に塞ぐなら、adapter 群を別パッケージにして
package.jsonのexportsで公開面を絞る。 - 隠蔽は推移的にかかる。公開面からたどれる型・シグネチャのどこにも内部依存が出てはいけない。
- 型とコンパニオンが同名だと
export { X }で両方漏れる。export type { X }で型だけ公開する。 - 漏れの検出は API Extractor の
ae-forgotten-exportで機械化できる。
遭遇した問題
- 環境: Tauri v2 のフロントエンド(TypeScript)。
invokeをCommandTransportでラップし、各 port(AppWindow,DocumentIpcなど)がcreate(transport: CommandTransport)で受け取る構成。 - コンポジションルートでは 1 つの
CommandTransportを全 port で共有している。
const transport = CommandTransport.create();
const ports = {
ipc: DocumentIpc.create(transport),
appWindow: AppWindow.create(transport),
// ...
} as const;
CommandTransport型を外から見えないようにしたいが、createの引数に型が出ているため、どこまで隠れているのか曖昧だった。
原因
- 依存を引数で受け取るファクトリは、そのシグネチャ自体が公開面になる。
AppWindow.create(transport)を外から呼べる限り、CommandTransportは見えている。 - TypeScript には「パッケージ内だけ公開」がない。ファイルから export した時点で、どこからでも import できる。
- コンパニオンオブジェクトを型と同名にしていると、
export { AppWindow }で型と値(create(transport)を持つ)が一緒に出ていく。 - Rust 側コマンド名の型(
AppWindowCommand)のような IPC の約束事も、export すれば内部事情の漏れになる。
解決
隠蔽レベルを選ぶ
| レベル | やること | 向いている場面 |
|---|---|---|
| 1. コンポジションルートだけが知る | 今の構成のまま + ESLint no-restricted-imports | アプリ内に閉じたコード。軽い |
| 2. ファサードで組み立てごと隠す | TauriPorts.create() / createNull() | コンポジションルートからも消したい |
| 3. 物理的に import 不可にする | 別パッケージ + exports + export type | 「完全に」隠したい |
2. ファサードに組み立てを押し込める
export const TauriPorts = {
create(): Ports {
return build(CommandTransport.create(), TauriDialog.create());
},
createNull(options: NullOptions = {}): Ports {
return build(
CommandTransport.createNull({
...AppWindow.nullResponses(options.appWindow), // 各 port が自分のコマンド名に分解する
}),
TauriDialog.createNull(options.dialog),
);
},
} as const;
// コンポジションルート
const ports = TauriPorts.create();
- 1 インスタンス共有は
buildの中で保たれる。 - テストでは
TauriPorts.createNull({ appWindow: { closeError: "window not found" } })のように port の振る舞いで書き、コマンド名への分解は各 port(AppWindow.nullResponsesなど)が持つ。port 単体のAppWindow.createNull()も同じ分解を使えばよい。
3. 別パッケージ + exports + export type
{
"name": "@app/tauri-adapters",
"exports": { ".": "./src/index.ts" }
}
// src/index.ts(唯一の公開面)
export { TauriPorts } from "./tauri-ports";
export type { Ports } from "./tauri-ports";
export type { AppWindow, AppWindowError } from "./app-window";
// AppWindow のコンパニオン(create(transport))・AppWindowCommand・CommandTransport は出さない
exportsに書いていないファイルはモジュール解決で拒否される。TypeScript もmoduleResolutionをbundler/nodenextにすれば型レベルで import できなくなる。CommandTransportや各 port 単体のテストはパッケージの内側に置くので、そこからは普通に使える。- コンパニオンに IPC と無関係な純粋関数が同居していて外から使いたい場合は、別のコンパニオン(または名前付き関数)に分ける。「口を作る責務」と「値を扱う責務」が同居していると片方だけ公開できない。
公開面チェックリスト
| 外から見えるもの | 判定 |
|---|---|
port の型(close(): Promise<Result<void, AppWindowError>>) | OK(Tauri を知らない) |
エラー型({ message }) | OK(生の Tauri エラーを詰めない限り) |
コンパニオンの create(transport) | NG → export type で型だけ公開 |
| コマンド名の型 | NG → export しない |
ESLint は「うっかり」を防ぐが外せば破れる。exports は入口の封鎖、API Extractor の ae-forgotten-export は「公開した型の中身から非公開の型を参照している」漏れの検出、と役割が違う。
コスト
- レベル 2 以降は port が増えるたびに
NullOptionsと各 port のnullResponsesを育てる必要がある。Nullables はテストのための本番コードを持つ、というトレードオフを受け入れる設計。 - 変換コードを書きたくなければ、使う側のテストは
Portsの各 port を手書き fake で差し替え、adapter 側はパッケージ内でmockIPCを使う。そうすればTauriPortsはcreate()だけで済む。
まとめ
- 隠蔽は「どこから見えないか」を決めてから選ぶ。完全に隠すなら、ファサードで組み立てを閉じ込め、
exportsで入口を塞ぎ、export typeで公開面からたどれる型をきれいに保つ。
参考
- James Shore, Testing Without Mocks(Parameterless Instantiation / Fake It Once You Make It / Configurable Responses)— https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks
- Node.js, Packages(
exportsによるカプセル化)— https://nodejs.org/api/packages.html - 2ality, TypeScript and native ESM on Node.js(package exports で内部を隠す)— https://2ality.com/2021/06/typescript-esm-nodejs.html
- API Extractor — https://api-extractor.com/