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

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 のようなものが実装されれば, この点は解消されるかもしれません.