TypeScriptにおける制約つき型のデザインパターン

## 概要

TypeScriptにおいて、ある性質 p を満たす型の集合 S\_p を考えるとき、 S\_p が有限集合なら全て書き下せば済みますが、あまりにも空間が巨大だったり、そもそも無限集合だったりする場合はそううまく行かないことがほとんどです。

本稿では、そのようなうまく行かない例としてUUID v4を取り上げます。

:::note info
UUID v4はUUIDの一種で、バージョンを表現する4ビット及びバリアントを表現する2ビット以外は全てランダムに埋められたUUIDです。
UUID v4としてとり得る値の種類は128ビットのうち6ビットが「固定」されるため、$2^{122}$個 あります。
:::

これを愚直にunion typeでTypeScriptへ埋め込もうとするとTS2590が発生して不可能[^1]です。
そこで、本来の目的に立ち返ってみましょう。本来の目的としては、定義域からはみ出す値を受け取りたくない、という動機が出発点のはずです。あなたはTypeScriptをそのために使ってきました。私もそうです。

しかし、たくさんあるUUID v4を列挙しようとしてもとても間に合いません。1秒に1個書き込んでも先にブラフマーの塔[^100]が移され終わってしまいます[^101]。
だからといって`string`にするとコンパイルエラーにできなくて癪です。これを実現するためによく知られた手法として篩型がありますが、TypeScript自体には入りそうにありません。
そこで、可能な型の組み合わせを埋め込まずに、型を使う時になって検証すればいいわけです。これは集合論における内包的記述に対応しますので、私はこれを「内包的型境界」と名付けました。

幸い、UUIDはかなり短いので1つ1つ列挙するよりも遥かに楽そうです。しかし、実装しようとすると何がどうなってるのか頭と`tsc`が爆発してわけが分からなくなることがほとんどでしょう。
本稿はその手法をできるだけわかりやすく解説した、あるいは私がそうなるための記事です。
[^100]: ブラフマーの塔はハノイの塔です。しかし、高さが64段あります。
[^101]: ブラフマーの塔は初期状態から他の柱に移すまで$2^{64}-1$回の移動が必要なので、追いつくためには1回移動するまでに平均$2^{62}$回列挙しなければなりません。そんなの無理です。

## 前提
といっても込み入ったトピックなので、最新かつある程度前提知識があることを前提にさせてください。もしわからない場合は各所にリンクを張っておいたのでそれを参考にしてください。

* TypeScript: 5.5.4
* 想定レベル: 型レベルのメタプログラミングをしたことがある人

## 戦略
基本的に[conditional type](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html)を連打します。conditional typeはチューリング完全なので何でもできます。
ですが、連打する前にconditional typeとその使い方についておさらいしておきます。

conditional typeとは、任意の型`A` が `Bound` のサブタイプかどうかで `True` か `False` に分岐する

```ts
A extends Bound ? True : False
```

のことを指すのでした。

### 型エイリアスの定義
型レベルのメタプログラミングをするときは、型エイリアスと組み合わせて、

```ts
type Hello<A> = A extends Bound ? True : False;
```

とするのが常套手段です。この手法は組み込みで用意されている [`Exclude`](https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers) などでも見ることができます。

ここで、 話を簡単にするため、
* `True` を `A` に、
* `False` を `never` に、
* `Bound` を `string` に

それぞれ固定した変種 `Hi` を考えてみます。

```ts
type Hi<A> = A extends string ? A : never;
```

こうすると、`Hi`は「もし `A` が `string` のサブタイプだった場合は `A` 、そうでない場合は `never` へ解決される」という型ユーリティリティーになります。

### 型エイリアスの活用
`Hi` を関数の引数の型として適用してみましょう。

```ts
function takeHi<A>(value: Hi<A>): void {}
```

こうすることで、`value`の型は
* もし`A`が`string`のサブタイプであれば`A`自身に、
* そうではない場合`never`に

解決されます。

後者のケースでは、`never`型の値を手に入れる前にプログラムの制御フローが離脱するか、コンパイルエラーになるため `takeHi`の呼び出しを阻止することができます。

### 親切なエラー
前のセクションでは解説のために通常[^11]絶対作り出せない`never`型を使いました。
実際には`never`以外の型でも本来来る値の型から代入できない型にすることでコンパイルエラーを起こせます。例えば、`string`が通常期待されるところの引数が`42`型になっていたらコンパイルエラーが通りません:

```ts
function takeString(value: 42): void {}

takeString("123"); // ~ ERROR!
```

この性質を利用して、条件を満たさないとき`never`に解決するのではなく、違う型に解決することを考えます。メタプログラミングを使う人にとって一番嫌われるのはC++のSFINAEのような[^300]分かりにくいエラーですから、コンパイラと格闘する手間を減らすようなエラーを構成したいところです。
そこで、天下り的ですが`unique symbol`をオブジェクトのキーとして使うようなエラーを考えます。

:::note info
[`unique symbol`](https://www.typescriptlang.org/docs/handbook/symbols.html) は`Symbol()`から返される特殊な型です。
TypeScriptの世界では[`typeof variable`](https://www.typescriptlang.org/docs/handbook/2/typeof-types.html)で明示的に由来を紐付けない限り宣言・作成毎に互いに異なる型として扱われます。
:::

オブジェクトのキーの名前は`myError`とでもしておきましょう。`myError`について、実体は要らないものの型情報を得るためにTypeScriptを`declare const`で騙します。騙しますが、あとで安全に閉じ込めるので一旦見ないふりをしてください。
エラーの時に解決される型として、`{[myError]: "ここにエラーメッセージ"}` という形式を取ると、プロパティの値の型に任意の型を入れられるので好き放題できます。さらに、普通の型からの代入は`[myError]`がないためできず、`{}`以外の型への代入はその他のプロパティが全て欠けているためできません。つまり、事実上独立した型です。これは`symbol`を用いた実装特有のメリットです。

理屈はこれぐらいにして、実際にやってみましょう。`Hi`の定義を次のように書き換えます。

```ts
declare const myError: unique symbol;
type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない！！"};
```

そして、`takeHi`を呼び出してみましょう。
```ts
takeHi("123"); // OK
takeHi(42);
//     !~
// /---/
// | Argument of type 'number' is not assignable to parameter of type '{ [myError]: "Aは文字列じゃない！！"; }'.(2345)
```

うまくコンパイルエラーにできました :tada:

### `as const` をつけなくてもできるだけ検証をできるようにする
TypeScriptにはリテラル型が存在します。リテラル型はその親分である`number`や`string`などよりも情報量が多いので、できれば勝手にリテラル型になってほしいところです。
しかし、上のエラーメッセージをよく見ると`Argument of type 'number' is not assignable to ...`と`42`なのに`number`型になってしまっていますね。これは情報量が減ってしまい悲しいです。
幸い、現代のTypeScriptは型変数の前に`const`修飾子を前置することで、可能であれば[`as const`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions)を呼び出し側で書いたかのような型推論を[してくれます](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#const-type-parameters)。

`takeHi` の定義を次のように変えます。

```ts
function takeHi<const A>(value: A & Hi<A>): void {}
//              ^~~~~ 注目！！
```

`<A>`が`<const A>`になったことが1点。
もう1点は推論を助けるために、`Hi<A>`を`A`自身とのintersection typeにしたことです。これ自体は推論を助ける効果しかありません。なぜなら`A & A`は`A`だし、`A & {[myError]: "Aは文字列じゃない！！"}`は`A`で満たすことができず、どっちにしてもコンパイル結果は変わらないからです。

:::note warn
`A`を`A extends Hi<A>`にしてしまうと「Type parameter 'A' has a circular constraint.(2313)」と言われて怒られます。
:::

これを踏まえて、呼び出し側のコードをにらみます。

```ts
takeHi("123"); // OK
takeHi(42);
//     !~
// /---/
// | Argument of type '123' is not assignable to parameter of type '123 & { [myErrorSymbol]: "Aは文字列じゃない！！"; }'.
//     Type 'number' is not assignable to type '{ [myErrorSymbol]: "Aは文字列じゃない！！"; }' (2345)
```

いい感じに推論されていますね！

### いい感じにモジュール化する

最後に、内部実装を押し込めてしまいましょう。この例示程度の規模であれば不必要にも思えますが、後で解説する本編で出てくるので書いておきます。

内部実装を押し込めるために、`export declare namespace`を使います。これは`declare namespace`を`export`する文です。

`declare namespace` はDefinitely Typedでも推奨されている[ghost module](https://definitelytyped.org/guides/best-practices.html) ([幽霊namespace](http://typescript.ninja/typescript-in-definitelyland/definition-file.html#fn-ghost-module)) です。

1. まず、`Hi`と`myError`の宣言部分を`declare namespace`で囲みます。`declare namespace`の後の名前は適当につけてください。ここでは`MyFirstTypeAssertion`とします:
    ```ts
    export declare namespace MyFirstAssertion {
        declare const myError: unique symbol;

        type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない！！"};
    }
    ```
2. `declare const`はエラーになるので`declare`修飾子を外します。意味は変わりません:
    ```ts
    export declare namespace MyFirstAssertion {
        const myError: unique symbol;
    
        type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない！！"};
    }
    ```
3. `Hi`を外のファイルからも使えるように`export`修飾子をつけます:
    ```ts
    export declare namespace MyFirstAssertion {
        const myError: unique symbol;
    
        export type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない！！"};
    }
    ```
4. 最後に、`{[myError]: "Aは文字列じゃない！！"}` を括りだしておきましょう:
    ```ts
    export declare namespace MyFirstAssertion {
        const myError: unique symbol;
    
        export type Hi<A> = A extends string ? A : MyFirstAssertion.Error<"Aは文字列じゃない！！">;

        type Error<Message> = {[myError]: Message};
    }
    ```

これで完成です。`myError`を間違って値として参照することもなくなり、必要のない時にうっかり参照されることもなくなり、非常にクリアかつセキュアなコードになりました。

:::note info
ESLintを使っている人へ:
[`@typescript-eslint/no-namespace`](https://typescript-eslint.io/rules/no-namespace/)の設定によっては`declare namespace`に対して発火するため、必要であれば適宜抑制してください。
:::

`takeHi`の方もこれに合わせて更新しておきます。

```ts
import type { MyFirstAssertion } from 'my-first-assertion.js';

function takeHi<const A>(value: A & MyFirstAssertion.Hi<A>): void {}
```

`import type` は型情報しか扱わないことを**明確に示す**構文です。詳しく説明しようとすると脱線するので、他の人の記事を見てください。
`takeHi`を呼び出す側の結果はそのままのため省略します。

ここまでのセクションで基本的な振る舞いは書き終えたので、あとはこれをいい感じ™に組み合わせるだけです。
[^11]: コントロールフローを抜けることでしか得たことにならない
[^300]: 免責: C++20以降はコンセプトがあるのでそのような黒魔術を扱う必要性は少なくなっています。、依然としてそうでないコードはそうでないままです。

## で、どうやってUUIDv4だけ受け付けるのさ

型レベルの世界にいきなり入ると混乱するので、まずは値レベルのコードでどういう戦略を取るか示します。

UUIDv4のフォーマットが`rrrrrrrr-rrrr-4rrr-Vrrr-rrrrrrrrrrrr` (`r` = ランダムなhex、`V`は8以上12以下のhex) であったことに注意すると、値レベルのコードでは次のように書けます。

```ts
function assertUUIDv4<S extends string>(s: S): S {
    const _m = s.match(/^(?<sa>.*?)-(?<sb>.*?)-(?<sc>.*?)-(?<sd>.*?)-(?<se>.*?)$/);
    if (!_m) {
        throw new RangeError('not a hyphnated UUIDv4');
    }
    
    const { sa, sb, sc, sd, se } = _m.groups;

    if (!sc.startsWith('4')) {
        throw new RangeError('contains invalid version');
    }

    const acceptedVariants = ['8', '9', 'a', 'b', 'A', 'B'] as const;
    if (!acceptedVariants.some(v => sd.startsWith(v))) {
        throw new RangeError('contains invalid or reserved variant');
    }
    
    if (sa.length !== 8 || sb.length !== 4 || sc.length !== 4 || sd.length !== 4 || se.length !== 12) {
        throw new RangeError('component lengths are required to be [8, 4, 4, 4, 12]');
    }

    if ([sa, sb, sc, sd, se].every(p => typeof assertHex(p) === 'string')) {
        return s;
    } else {
        throw new RangeError('some components are not hex-string');
    }
}
```

頭の中で標準ライブラリを思い出しながら書いたので、間違っていたらこっそり教えてください。
処理の流れはある程度つかめると思うので詳説は割愛します。

これを型レベルでやります。全部読む必要はありません。

([下まで飛ばす用のリンク](#解説))

https://gist.github.com/KisaragiEffective/476ab4b47a78b6d982ac37970dfc4c86

### 解説
ごちゃごちゃと書いてありますが重要なところは`declare module UUIDv4`の内側です。それ以外は正規表現とか`length`プロパティとか親切なものが型レベルの世界には全くないために末尾再帰で強引に解決しているユーティリティです。
ここで改めて特筆すべきことは
* タプル型の`length`プロパティがリテラル型になること
* Template Literal Typeに対して`${infer A}${infer B}`のようにすると`A`には先頭の1文字だけが入ること: `""`に対しては`B`がマッチできないのでマッチしない判定になる
* 文字列リテラルの`length`プロパティは数値リテラル型ではないこと

ぐらいでしょうか。

落ち着いて`UUIDv4.Assert`を眺めると、先ほどと同様条件分岐の直列な連鎖であることがわかります。
1. 初手でリテラル型ではない`string`型が来たら全ての静的チェックを諦めます。これは`string`型について何が来るかわからない以上、利便性を優先する措置です
1. Conditional Typesの右辺に`infer`句がついた[Template Literal Type](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)を持ってきて、先程の正規表現同様にマッチを行います。これによって、`"---"`や`""`のようなパターンが弾かれます
1. ハイフンで区切られた部分文字列の3番目が`4`で始まることをチェックします
1. ハイフンで区切られた部分文字列の4番目が`8`、`9`、`a`、`b`、`A`、`B`のどれかで始まることをチェックします。
    Template literal Typeの中にunion typeを`${}`で補間すると、そのunion typeの各要素が分配されます。例えば、
    ```ts
    type ABC = 'A' | 'B' | 'C';
    type X = `${ABC}D`;
    //   ^? 'AD' | 'BD' | 'CD'
    ```
    が成り立ちます。
1. ハイフンで区切られた部分文字列が、8桁―4桁―4桁―4桁―12桁かチェックします
1. ハイフンで区切られた部分文字列のそれぞれについて、`AssertHex` で正規表現 `/^[0-9a-fA-F]*$/` に沿っていることをチェックします
1. 全てのチェックを通過したら与えられた型引数をそのまま返し、そうでなければ適切なエラーを出力します

## 活用例

先程定義した`UUIDv4.Assert<S>`を用いて、UUIDだけ受け付ける関数は次のように書くことができます。

```ts
function acceptsUUIDv4<const S extends string>(
    uuidLiteral: S & UUIDv4.Assert<S> & LiteralType.Assert<S>
): void {
    console.log(`happy UUID life from ${uuidLiteral} :)`);
}
```

`LiteralType.Assert<S>`はリテラル型でない`string`をはじく架空の型ユーティリティです。本筋とは外れる上に解説するほど面白みがないので実装はみなさんへの課題とします。

メタプログラミングで頑張ったご褒美として、呼び出しにも型がつきます。
```ts
   acceptsUUIDv4("123456");
//               !~~~~~~~
// /-------------/
// |
// Argument of type '"123456"' is not assignable to parameter of type '"123456" & AssertionError<"123456 is not a hyphnated-UUIDv4">'.
//   Type 'string' is not assignable to type 'AssertionError<"123456 is not a hyphnated-UUIDv4">'.(2345)
```

TS2345が型を潰さずにいてくれるおかげで、TypeScriptの独特なエラー表示を最大限活用したわかりやすいエラーメッセージになっています。

### 事例

この手法と類似の手法を @uhyo さんが作った [better-typescript-lib](https://github.com/uhyo/better-typescript-lib/) に[提案](https://github.com/uhyo/better-typescript-lib/issues/37)して[受理](https://github.com/uhyo/better-typescript-lib/pull/38/commits/79fef6aa79dad02fb8f450ad6ec7a0d451d439ed)されています。

## 合成可能性
さきほど気づいた人も居るかもしれませんが、内包的型境界はintersection typeによって同時に満たさなければならない条件として合成可能です。
例えば2000年以降に付番され、かつUUIDv7の書式にそぐう文字列のリテラル型は

```ts
function acceptsRecentUUIDv7<const S extends string>(
    uuidV7Literal: S & UUIDv7.Assert<S> & UUIDv7.AfterAD2000<S>
): void {
    console.log(`Happy recent and sortable UUID life from {uuidV7Literal} :)`);
}
```

として表現できます。

ここまで理解できた人なら自力で実装できるはずですので、よかったら頑張ってみてください。

## あとがき
いかがでしたか？もし分かりにくい点があればコメント欄でご指摘いただけると幸いです。

余談: WebStormでポチポチ実験してる時にスペルチェッカーがUUIDだけスキップしたのでIntelliJ Community Editionの実装を見てみたら、[UUIDv4に対して特別扱い](https://github.com/JetBrains/intellij-community/blob/22243811e3e8342918b5c064cbb94c7886d8e3ed/spellchecker/src/com/intellij/spellchecker/inspections/PlainTextSplitter.java#L116)が入っていました。

[^1]: $2^{122} \fallingdotseq 5.316 \times 10^{36}$ パターンあるため、1パターンを1ビットで表せたとしても全世界のデータ総量を超えてしまいます。