型に対する述語で引数に制約をかける

以前にも同様の記事を書きましたが, 今回はその改訂 + α です. 動作は TypeScript 5.0.4 で確認しています.

Playground で試しながら読むとわかりやすいかもしれません.

おさらい

まずは TypeScript において, 関数に渡される引数に制約をかけたいときに通常使われる方法について思い出しましょう. 要するに引数に対する型注釈 (x: T) のことですね.

function myFunction(str: string): void {
  console.log(str);
}

myFunction("foo"); // OK
myFunction(42);    // Error

同じように, 型エイリアスなどの型引数に対しても制約をかけたいこともあります. これは通常は型引数に対して上界 (T extends U) を指定することで実現されます.

type MyType<S extends string> = {
  value: S;
};

type A = MyType<"foo">; // OK
type B = MyType<42>;    // Error

通常の方法の限界と型に対する述語

上記二つの方法で用いられている制約は, どちらもある型 (引数の型, または型引数そのもの) が別の型 (引数の型注釈や上界) の部分型かどうかの判定 (TypeScript では extends で表現されるもの) を使ったものです.

TypeScript の言語仕様上, この判定によって表現できる制約の範囲には限界があります. 具体的には, 型注釈や上界の型は型として具体的にインスタンス化されている必要があるため, 例えば要素数が無限の集合 (素数など) や, 有限であっても計算量や記憶容量の限界を超えるような集合 (長さ 3 の文字列など) のように, 実際にインスタンス化ができないものは制約に使うことができません.

一方で TypeScript の型レベル計算の能力を使えば, 上記のような型としてはインスタンス化できないようなものであっても, 型に対する述語の形であれば (集合でいうところの外延的表記ではなく内包的表記で) 定義することができます. 例えば以下は型 S が長さ N の文字列リテラル型かどうかを判定する述語 HasLength<S, N> の定義です (ここでは中身は理解する必要はなくて, こういったものが定義できるということだけ覚えてください).

type HasLength<S extends string, N extends number> = Length<S> extends N ? true : false;

type Length<S extends string, C extends unknown[] = []> =
    string extends S ? number
  : S extends "" ? C["length"]
  : S extends `${infer _H}${infer R}` ? Length<R, [...C, unknown]>
  : never;

このように型としては定義できなくても, 型に対する述語としては定義できるものを使って, 引数に対して制約をかけたいということはしばしばあります. 私だけでしょうか?

述語を使って引数に対して制約をかける

ここでは例として上記の HasLength<S, N> を使って, 長さ 3 の文字列だけを引数として受け取るような関数や型エイリアスを定義してみましょう. このような文字列は単純には 248 通りの組み合わせがあるので, 通常の方法を使って制約をかけることは実質不可能です.

まずは関数を定義してみましょう... とその前に, 天下り的ですが以下のようなユーティリティを定義します.

type Filtered<U, T> = T extends true ? U : never;

これと述語を組み合わせるとあら不思議, 目的の関数が以下のように定義できます.

function myFunction<S extends Filtered<string, HasLength<S, 3>>>(str: S): void {
  console.log(str);
}

myFunction("abc");  // OK
myFunction("ab");   // Error
myFunction("abcd"); // Error

これでなぜ動作するのかは説明がややこしくなる (というよりは正しく説明できる自信がない) ので割愛しますが, 例えば制約を満たす引数 "abc" を与えた場合は S = "abc" となり, 型検査を通過します. 逆に制約を満たさない引数 ("ab" など) を与えた場合は最終的に S = never となり, ここには何も代入できないため引数は型エラーとなります.

エイリアスの場合も, 関数の場合と全く同じように型引数に上界を与えることで, 述語による制約を満たした型引数のみを受け付けるように定義することができます.

type MyType<S extends Filtered<string, HasLength<S, 3>>> = {
  value: S;
};

type A = MyType<"abc">;  // OK
type B = MyType<"ab">;   // Error
type C = MyType<"abcd">; // Error

このようにして無事目的の関数や型エイリアスが定義できたわけですが, この方法には一つだけ注意点があって, 引数 (型引数) が制約を満たさなかった場合のエラーメッセージは, それぞれ以下のように具体的な制約についての説明が一切なく, デバッグに全く役立たないものになってしまいます.

  • Argument of type 'string' is not assignable to parameter of type 'never'.
  • Type 'string' does not satisfy the constraint 'never'.

そもそもあまり頻繁に使うものでもないとは思いますが, ここぞという場面でのみ使うのが無難かもしれません.

おまけ

T extends Filtered<U, Predicate<T>> の部分では制約をかけたい型引数 T とは異なる上界 U を使っていますが, これを T extends Filtered<T, Predicate<T>> のように T 自身に変えてしまうと Type parameter 'T' has a circular constraint. というエラーになり, コンパイルが通りません. U には適当な上界として述語の定義域や, あるいは特にない場合は unknown を指定しておきましょう.

以前の記事では関数の引数のみについて同様の制約をかける方法を紹介しましたが, 今回紹介した方法は型エイリアスなどの型引数にも適応できる, 述語とは別の型定義が個別に必要だったのが Filtered<U, T> で抽象化されている, 全く関係のない型の引数を与えた時のエラーメッセージがマシになっているなど, 改良版と言えるものになっていると思います.

2023-05-28 追記

述語がある程度 (?) 複雑な場合, TypeScript コンパイラが型引数に対する制約をうまく扱えずにエラーになることがあります.

例えば「TypeScript で実行時の入力を含む文字列を型で弾く」で紹介した IsFiniteString<T> をそのまま使うと, 型引数に対する制約が再帰的であると怒られてしまいます.

type IsFiniteString<T extends string> =
    string extends T ? false
  : T extends "" ? true
  : T extends `${infer H}${infer R}` ? (
      string extends H ? false
    : `${number}` extends H ? false
    : `${bigint}` extends H ? false
    : IsFiniteString<R>
  )
  : never;

function myFunction<S extends Filtered<string, IsFiniteString<S>>>(str: S): void {
//                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//                            ^ Type parameter 'S' has a circular constraint.
  console.log(str);
}

このようなエラーが出た場合は, 以下のように述語が表面上は単純に見えるように書き換えてやると, コンパイラがうまく解釈してくれることがあります.

type IsFiniteString<T extends string> = _IsFiniteString<T> extends true ? true : false;
type _IsFiniteString<T extends string> =
    string extends T ? false
  : T extends "" ? true
  : T extends `${infer H}${infer R}` ? (
      string extends H ? false
    : `${number}` extends H ? false
    : `${bigint}` extends H ? false
    : IsFiniteString<R>
  )
  : never;

function myFunction<S extends Filtered<string, IsFiniteString<S>>>(str: S): void {
  console.log(str);
}

myFunction("abc" as const);         // OK
myFunction("abc" as "abc" | "def"); // OK
myFunction("abc" as string);        // Error
myFunction("abc" as `a${string}c`); // Error

このあたりの細かいテクニックはコンパイラの気持ちを察するゲームになってしまうので, 手を出す時はそれなりに強い気持ちを持ちましょう.