引数の型を推論してから受け付けるかどうかを決める後出しパターン

2023-05-24 追記

改訂 + α 版を書きました.

お題

具体例として, ちょうど長さ 3 の文字列のみを引数として受け付ける関数を作ります. こんな関数を作って何がしたいのかは不明.

先出しパターン

よくある手段としては「ちょうど長さ 3 の文字列」のような制約を満たす型をあらかじめ定義しておいて, 引数の型としてそれを使うという方法です.

type StringOfThreeChars = /* ちょうど長さ 3 の文字列 */;

declare function myFunc(str: StringOfThreeChars): void;

myFunc("ab");   // error
myFunc("abc");  // ok
myFunc("abcd"); // error

では StringOfThreeChars はどう定義するとよいでしょうか? おそらく現時点 (TypeScript 4.2) でできる最大限の努力をすると, 以下のように制約を満たすパターンを union type として列挙しておくことになると思います.

type Char = "\u0000" | "\u0001" | ... | "\uFFFF";
type StringOfThreeChars = `${Char}${Char}${Char}`;

こんなの絶対書きたくないですし, そもそも StringOfThreeChars はパターン数が多すぎるのでコンパイラが爆発すると思います (試してないです).

後出しパターン

こんなときには別の方法があります (Playground).

type StringOfThreeChars<S extends string> = /* S の長さが 3 かの判定 */ ? S : never;

declare function myFunc<S extends string>(str: StringOfThreeChars<S>): void;

myFunc("ab");   // error
myFunc("abc");  // ok
myFunc("abcd"); // error

まずはこの関数を myFunc("abc") のように呼び出したとき, TypeScript がどのような処理を行うかを見てみましょう.

最初に引数 "abc" から型引数 S を推論しようとします. 引数の型は conditional type なのですが, このような場合は結果の型が S または never であることを使って, S = "abc" とうまく推論してくれます. S extends string としているので widening (リテラル"abc"string に拡大される) は起こりません.

無事 S が推論されたので, 続けて引数の型 StringOfThreeChars<S> を計算します. S = "abc" の場合はちょうど長さ 3 なので StringOfThreeChars<S> = "abc" となり, myFunc("abc") という呼び出しは型が矛盾しないので受け付けられることになります.

では myFunc("ab") のように長さが 3 でない文字列で呼び出された場合はどうなるかというと, S = "ab" が推論され, StringOfThreeChars<S> = never となります. "ab"never に代入できないので, この関数呼び出しは受け付けられません.

ということでこの方法で引数の型に制約をつけられることがわかりましたが, では制約の判定を行うにはどうしたらよいでしょうか. もしここであらかじめ制約を満たすものを列挙しないといけないのであれば, 先出しパターンでの欠点は克服できません.

type _StringOfThreeChars = /* ちょうど長さ 3 の文字列 (定義不可能) */;

type StringOfThreeChars<S extends string> = S extends _StringOfThreeChars ? S : never;

重要になるのは, ここでは引数の型 S が具体的に得られているので, それが制約を満たしているかの判定が行えれば十分ということです. そして TypeScript の型はとても表現力が高いので, 文字列の長さが 3 かどうかという判定は記述することができてしまいます.

// 文字列の長さのカウント (説明省略)
type CountChars<S extends string, C extends unknown[] = []> =
    string extends S ? number
  : S extends "" ? C["length"]
  : S extends `${infer A}${infer B}` ? CountChars<B, [...C, unknown]>
  : never;

type StringOfThreeChars<S extends string> = CountChars<S> extends 3 ? S : never;

よかったですね.

後出しパターンの欠点

myFunc("ab") のように制約を満たさないときのエラーは Argument of type 'string' is not assignable to parameter of type 'never'. のような, 制約が何であるのかを含まない不親切なものになってしまうことには注意が必要です. 将来的に throw types のようなものが実装されれば, この点は解消されるかもしれません.

セルフコードレビュー

自分の書いたコードのレビューを依頼するとき, 必ず先にセルフレビューするようにしていて, まあまあ上手くいっていると思っている.

元々のモチベーションとしてはコードの品質の向上と (本物の) レビュワーとのコミュニケーションのつもりだったけど, 本来レビュー時に行われるはずだったやりとりが削減されることで開発が高速化される効果もありそう.

ルフレビューにかける時間は, 典型的な規模 (差分 500 行以下くらい) の場合で修正込みでだいたい 15 分 〜 30 分程度で, そこに至るまでの実装にかけている時間が 1 〜 2 時間くらいと考えるとまあまあ時間は割いている. ただこれは他の人のレビューを待って, 一往復に数時間 〜 数日かけてやりとりして同じ変更を加えるよりはずっとマシ.

何かの参考になるかもしれないので私のやり方を書いておきます:

  • 他の人のコードをレビューするときと全く同じ方法で行っている
    • 私の場合 GitHub 上で見て, 現在の Pull Request の差分のみに注目している
    • コードを書くエディタ上とは環境を変えることで, 客観視しやすくもあると思っている
  • 注目するところも他の人のコードのレビューと同じ
    • 設計
      • イケてるか
      • 意図を説明できるか
    • 実装
      • 規約や慣例に従っているか
      • 命名や記述に一貫性があるか
      • コードが読みやすいか
      • コードやコメントなどから実装の背景や意図が伝わるか
      • リファクタリングの余地があるか
    • テスト
      • 意図通りの内容をテストできているか
      • 網羅性は十分か
  • 洗い出された問題点を修正する
  • 加えて本物のレビュワーへの手紙をレビューコメントとして残す
    • 背景や意図が伝わりにくい箇所を補足する
    • 現時点ではまだ仮実装であるといった文脈を補完する
    • 自信がない箇所であれば表明して意見を求める
  • 書いたレビューコメントを見直して, コード中にコメントを残した方が良い内容であればコードに転記する
    • 例えば背景や意図が伝わりにくい箇所は, 今からコードを読むレビュワーだけではなく, 後からコードを読む人にとっても伝わりづらいはず

やり方は違うかもしれないものの似たようなことをしている人はもちろんいて, レビューする側として見た場合に, セルフレビューを経て細かい問題が取り除かれ, 適宜補足コメントなども書かれたコードのレビューはとてもやりやすいという印象も持っている.

f:id:susisu:20210308022313p:plain