10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-08-09

概要

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

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

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

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

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

幸い、UUIDはかなり短いので1つ1つ列挙するよりも遥かに楽そうです。しかし、実装しようとすると何がどうなってるのか頭とtscが爆発してわけが分からなくなることがほとんどでしょう。
本稿はその手法をできるだけわかりやすく解説した、あるいは私がそうなるための記事です。

前提

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

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

戦略

基本的にconditional typeを連打します。conditional typeはチューリング完全なので何でもできます。
ですが、連打する前にconditional typeとその使い方についておさらいしておきます。

conditional typeとは、任意の型ABound のサブタイプかどうかで TrueFalse に分岐する

A extends Bound ? True : False

のことを指すのでした。

型エイリアスの定義

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

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

とするのが常套手段です。この手法は組み込みで用意されている Exclude などでも見ることができます。

ここで、 話を簡単にするため、

  • TrueA に、
  • Falsenever に、
  • Boundstring

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

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

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

型エイリアスの活用

Hi を関数の引数の型として適用してみましょう。

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

こうすることで、valueの型は

  • もしAstringのサブタイプであればA自身に、
  • そうではない場合never

解決されます。

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

親切なエラー

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

function takeString(value: 42): void {}

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

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

unique symbolSymbol()から返される特殊な型です。
TypeScriptの世界ではtypeof variableで明示的に由来を紐付けない限り宣言・作成毎に互いに異なる型として扱われます。

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

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

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

そして、takeHiを呼び出してみましょう。

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

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

as const をつけなくてもできるだけ検証をできるようにする

TypeScriptにはリテラル型が存在します。リテラル型はその親分であるnumberstringなどよりも情報量が多いので、できれば勝手にリテラル型になってほしいところです。
しかし、上のエラーメッセージをよく見るとArgument of type 'number' is not assignable to ...42なのにnumber型になってしまっていますね。これは情報量が減ってしまい悲しいです。
幸い、現代のTypeScriptは型変数の前にconst修飾子を前置することで、可能であればas constを呼び出し側で書いたかのような型推論をしてくれます

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

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

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

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

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

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 namespaceexportする文です。

declare namespace はDefinitely Typedでも推奨されているghost module (幽霊namespace) です。

  1. まず、HimyErrorの宣言部分をdeclare namespaceで囲みます。declare namespaceの後の名前は適当につけてください。ここではMyFirstTypeAssertionとします:
    export declare namespace MyFirstAssertion {
        declare const myError: unique symbol;
    
        type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない!!"};
    }
    
  2. declare constはエラーになるのでdeclare修飾子を外します。意味は変わりません:
    export declare namespace MyFirstAssertion {
        const myError: unique symbol;
    
        type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない!!"};
    }
    
  3. Hiを外のファイルからも使えるようにexport修飾子をつけます:
    export declare namespace MyFirstAssertion {
        const myError: unique symbol;
    
        export type Hi<A> = A extends string ? A : {[myError]: "Aは文字列じゃない!!"};
    }
    
  4. 最後に、{[myError]: "Aは文字列じゃない!!"} を括りだしておきましょう:
    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を間違って値として参照することもなくなり、必要のない時にうっかり参照されることもなくなり、非常にクリアかつセキュアなコードになりました。

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

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

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

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

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

ここまでのセクションで基本的な振る舞いは書き終えたので、あとはこれをいい感じ™に組み合わせるだけです。

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

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

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

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');
    }
}

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

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

(下まで飛ばす用のリンク)

解説

ごちゃごちゃと書いてありますが重要なところはdeclare module UUIDv4の内側です。それ以外は正規表現とかlengthプロパティとか親切なものが型レベルの世界には全くないために末尾再帰で強引に解決しているユーティリティです。
ここで改めて特筆すべきことは

  • タプル型のlengthプロパティがリテラル型になること
  • Template Literal Typeに対して${infer A}${infer B}のようにするとAには先頭の1文字だけが入ること: ""に対してはBがマッチできないのでマッチしない判定になる
  • 文字列リテラルのlengthプロパティは数値リテラル型ではないこと

ぐらいでしょうか。

落ち着いてUUIDv4.Assertを眺めると、先ほどと同様条件分岐の直列な連鎖であることがわかります。

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

活用例

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

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をはじく架空の型ユーティリティです。本筋とは外れる上に解説するほど面白みがないので実装はみなさんへの課題とします。

メタプログラミングで頑張ったご褒美として、呼び出しにも型がつきます。

   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提案して受理されています。

合成可能性

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

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に対して特別扱いが入っていました。

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

  2. ブラフマーの塔はハノイの塔です。しかし、高さが64段あります。

  3. ブラフマーの塔は初期状態から他の柱に移すまで$2^{64}-1$回の移動が必要なので、追いつくためには1回移動するまでに平均$2^{62}$回列挙しなければなりません。そんなの無理です。

  4. コントロールフローを抜けることでしか得たことにならない

  5. 免責: C++20以降はコンセプトがあるのでそのような黒魔術を扱う必要性は少なくなっています。、依然としてそうでないコードはそうでないままです。

10
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?