戻り値にしか出てこない型引数は as の言い換え — 直したあと、テストが revert を検出しないことに気づく

戻り値にしか出てこない型引数は as の言い換え — 直したあと、テストが revert を検出しないことに気づく

プラグイン用の型定義パッケージに、こういうシグネチャが4つ並んでいました。

dispatch<T = unknown>(args: object): Promise<T>;

呼び出し側は dispatch<Bookmark[]>(args) と書けます。一見すると型が付いていて安全そうです。しかし T引数のどこにも現れません。コンパイラには T を推論する材料が一つもないので、呼び出し側が書いた型がそのまま通ります。つまりこの2行は同じものです。

const list = await dispatch<Bookmark[]>(args);
const list = (await dispatch(args)) as Bookmark[];

違うのは、下の行だけがレビューで見え、consistent-type-assertions にも引っかかるという点です。上の行はアサーションを API の形の中に埋め込んで見えなくしているだけでした。

嘘をつくことが実装者の義務になる

もっと悪いのは、このアサーションが実装側に押し付けられることです。Promise<T> を返すと約束した以上、未検証の JSON からそれを作る方法は json as T しかない。API が実装者に嘘を強制していました。

実際、このリポジトリのテスト用フェイクは dispatch: async () => ({}) と書いていました。テストが yarn typecheck の対象外だったので誰も気づいていませんでしたが、対象に入れた瞬間にエラーになりました。コンパイラは最初から怒る準備をしていて、ただ誰も聞いていなかった。

そして厄介なことに、実行時の挙動は1ミリも変わりません。変わるのはコンパイラが何を信じるかだけ。サーバが { error: "..." } を返しても list[0].url は型検査を通り、undefined を触った瞬間に、原因から遠く離れた行で落ちます。

直し方は「宣言」を「推論」に変えるだけ

T を呼び出し側の自己申告ではなく、渡された証拠から導出させます。

dispatch(args: object): Promise<unknown>;
dispatch<T>(args: object, parse: (raw: unknown) => T): Promise<T>;

Tparse の戻り値型から決まるので、型と検査が食い違えなくなりました。Zod なら dispatch(args, (raw) => Bookmarks.parse(raw)) です。

判定ルールとしてはこうなります。

型引数が引数の位置に現れるなら推論される(嘘をつけない)。戻り値にしか現れないなら、それは呼び出し側の未検証の主張。

同じパッケージの publish<T>(name, payload: T) は逆で、T が引数側にあるので嘘はつけません。ただし型は JSON を越えないので購読側に届かない。つまり無意味なだけ。こちらは修正ではなく削除しました。unsound(嘘をつける)と vacuous(無意味)は別物です。

ここからが本題 — テストが revert を検出しない

修正の検証として、まず「アサーションを一切使わないリファレンスホスト実装」を書きました。これがコンパイルできれば、ホストが実装可能であることの証明になります。

念のため確かめました。シグネチャを古い形に戻して、typecheck が赤くなるかどうか。

$ npx tsc --noEmit -p tsconfig.json
EXIT=0

緑のままでした。

理由は、TypeScript がオーバーロードされた source を緩く比較するからです。オーバーロード集合を代入先に照合するとき、どれか1つが適合すれば通ります。新しい2つのオーバーロードを持つ実装は、古い dispatch<T = unknown>(args): Promise<T> も同じように満たしてしまう。

リファレンス実装が証明するのは「その形が実装可能であること」であって、「型がその形であること」ではない。

そこで、型そのものを固定するテストを別に足しました。代入可能性ではなく同一性で比較します。

/** True only for types that are mutually identical, not merely assignable. */
type IsExact<A, B> =
  (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2
    ? true
    : false;

type Expect<T extends true> = T;

export type DispatchIsPinned = Expect<
  IsExact<
    BrowserPluginRuntime["dispatch"],
    {
      (args: object): Promise<unknown>;
      <T>(args: object, parse: (raw: unknown) => T): Promise<T>;
    }
  >
>;

6パターンの mutation を試して、すべて赤くなることを確認しました。4つのシグネチャそれぞれの revert に加えて、「parse を optional にする」「parse が拒否できない(=> T | null=> T にする)」という惜しい間違いも検出します。

教訓は単純です。pin を足したら、pin している対象を壊して赤くなることを必ず確認する。 落ちないテストは、カバレッジがあるように読める分、無いより悪い。

おまけ: void を返す引数型は、あらゆる戻り値を受け入れる

購読側は当初こういう形にしていました。

subscribe<T>(name: string, parse: (raw: unknown) => T | null, handler: (payload: T) => void): () => void;

レビューで気づいたのですが、ハンドラを忘れた subscribe(name, parse) が通ります(payload: unknown) => void は戻り値型が void なので、あらゆる戻り値の単項関数を受け入れるからです。結果、validator がハンドラとして登録され、全フレームが parse されて捨てられる。コンパイル時も実行時もエラーは出ません。

parse を末尾に移す案も試しましたが、裸の関数がハンドラ引数を満たす事実は変わらないので閉じません。閉じたのはオブジェクトに包む形でした。

subscribe(name, handler)              // 未検証(payload は unknown)
subscribe(name, { parse }, handler)   // 検証あり
subscribe(name, { parse })            // ← 型エラー。オブジェクトは呼び出せない

同じパッケージの fetchJson(url, { parse }) と形が揃ったのは結果論ですが、関数を引数に取るAPIで、隣り合う引数が両方とも関数になるときは、片方を省いたら型エラーになるか確かめるというのは一般則として使えます。

最後にもう一つ

{ parse: (raw) => MySchema.parse(raw) } はこのパッケージ自身が推奨している書き方で、そして Zod の parse は throw します。購読にコピーすると、1つの不正フレームで購読が死に、同じチャンネルの他の購読者も巻き添えになる。仕様として「throw も drop 扱い、ホストは catch して次のフレームへ進むこと」と明記し、テストで固定しました。

リーダーを受け取る API を作るときは、そのリーダーが throw したら何が起きるかを必ず決めておく必要があります。決めていなければ、それは決まっていないのではなく、たまたま今そう動いているだけです。

この記事をシェア

関連記事

記事一覧に戻る