🤿

TypeScript中級者向け!型レベルプログラミングの紹介

2024/12/09に公開

こちらはTSKaigi Advent Calendar 2024の10日目の記事です。

対象者

この記事は、以下の方を対象にしています。

  • TypeScriptを用いてコードを書いたことがある方
  • 型レベルプログラミングを知らない、書いたことがない方

今回の記事では、以下のような「年齢に応じた割引値を返す処理」を、プログラムを実行せずに結果が得られるように実装する方法を紹介します。

function calculateUserDiscount(age: number): number {
    let discount = 0;
    if (age < 18) { // 学生割引
        discount = 1000;
    } else if (age >= 65) { // シニア割引
        discount = 1500;
    }
    return discount;
}

型レベルプログラミング

型レベルプログラミングとは、型システムを利用して、プログラムの振る舞いや制約を表現する手法です。数学的手法(論理学や集合論など)に基づいて設計され、型システムの一環としてコンパイル時に評価されるため、プログラムを実行する前に結果がわかります

では早速、型レベルプログラミングで書かれた「年齢に応じた割引値を返す処理」のコードを見てみましょう。

UserDiscountという型に、型引数として年齢を与えると、割引額が取得できます。

type typecheck17 = UserDiscount<17>; // 1000
type typecheck18 = UserDiscount<18>; // 0

特筆すべきは、この結果がコードを実行する前にわかるということです。より具体的にいうと、この結果はコンパイル時にわかります。

その証拠に、36行目以降のtypecheckをホバーして型の情報をみると、割引額がわかります。


ざっとコードを見ると、変数や値のようなものは定義されておらず、すべてが型で記述されていることがわかります。

型レベルプログラミングにおける型とは

コードの解説の前に、型レベルプログラミングにおける、型について説明します。型レベルプログラミングにおける型は、数学でいう集合のようなものです。以下のコード解説には、集合論が深く関係します。

基本的な型の定義

type True = true;
type False = false;
type Bool = True | False;

この部分ではTrue型とFalse型、それらのユニオン型(集合論でいうところの和集合)としてのBool型を定義しています。図で表すと以下の通りです。

型の一致を判定

type Equal<A, B> = A extends B ? (B extends A ? True : False) : False;

Equal型は、入力として型Aと型Bを受け取ります。型Aと型Bが互いに互いを包含する(A = B)場合にTrueを返し、それ以外の場合、Falseを返します。

extendsについて

extendsは、集合論における部分集合「含む」を表します。つまり、A extends BA ⊂ Bであり、ここでは「AはBの部分集合であるか」を表しています。

type A = "a";
type B = "b";
type C = "c";
type AandB = A | B;

type check1 = B extends AandB ? true : false; // true (BはAandBの部分型)
type check2 = C extends AandB ? true : false; // false (CはAandBに含まれない)

extendsから話を戻すと、Equal型は、A extends BB extends Aの両方を満たす場合、Trueになります。つまり、互いに互いを包含する(A = B)場合、Trueになります。

サンプル

type typecheck1 = Equal<True, True>; // True
type typecheck2 = Equal<4, 2>; // False
type typecheck3 = Equal<42, 42>; // True

条件分岐

type IF<C extends Bool, TS, FS> = C extends True ? TS : FS;

IF型は、入力としてC(条件)、TS(条件がTrueの場合の結果)、FS(条件がFalseの場合の結果)を受け取ります。C(条件)がTrueの場合、TSを返し、そうでない場合はFSを返します。

extendsについて

実はextendsは文脈によって意味が変わります。

=の左側にextendsがある場合

型制約を定義するために使われます。IF型定義の=の左のC extends Boolの部分は、CがBoolの部分集合であるということを表し、この型制約を満たさない型を渡すとコンパイルエラーとなります。

*満たさない場合、コンパイルエラー

=の右側にextendsがある場合

条件型を定義するために使われます。条件型は、T extends U ? A : Bのように書きます。これはTUの部分集合である場合、Aになり、そうでない場合Bになることを表します。

IF型定義の=の右のC extends True ? TS : FSは、もしCがTrueの部分集合である場合、IF型はTS型になり、部分集合でない場合は、IF型はFS型になることを表しています。


*満たすかどうかを判定

サンプル

type typecheck1 = IF<True, 1, 2>; // 1
type typecheck2 = IF<Equal<4, 2>, 5, 10>; // 10
type typecheck3 = IF<Equal<42, 42>, 40, 2>; // 40

N >= Uかを判定

type IsMoreThanOrEqualTo<
  N extends number,
  U extends number,
  Current extends number[] = []
> = Current["length"] extends U
  ? True
  : Current["length"] extends N
      ? False
      : IsMoreThanOrEqualTo<N, U, [...Current, 1]>;

NUを比較し、N >= Uの場合にTrueを、それ以外の場合にFalseを返します。

再帰構造を持つため少し複雑なので、図を示して説明します。
この型は、Currentというnumberの配列の型を持ちます。

Currentは初期状態は空っぽです。そのためCurrent["length"]は0です。

まず初めに、Current["length"] extends UCurrent["length"]Uの部分集合か判別しています。

ここからは、N >= UTrueになるケースを考えてみましょう。
例えば、Uを3、Nを5として考えてみます。

初めのCurrent["length"] extends Uでは、0 extends 3であり、これは0 === 3と考えてみましょう。これは満たされないため、次のCurrent["length"] extends Nが評価されます。これは、0 === 5であり、これも満たされないため、IsMoreThanOrEqualTo<N, U, [...Current, 1]>;が呼ばれ再帰します。この時、Currentには[...Current, 1]が渡されます。その結果、Current配列型に1が追加され、Current["length"]は1になります。

ここでも、Current["length"]は1のため、Current["length"] extends UCurrent["length"] extends Nつまり、1 === 31 === 5は満たされず、次の再帰に移ります。その結果、Current配列型に1が追加され、Current["length"]は2になります。

ここでも、Current["length"]は2のため、Current["length"] extends UCurrent["length"] extends Nつまり、2 === 32 === 5は満たされず、次の再帰に移ります。その結果、Current配列型に1が追加され、Current["length"]は3になります。

ここでは、Current["length"]が3のため、Current["length"] extends Uつまり、3 === 3が満たされ、条件型はTrueを返します。


ここからは、N >= UFalseになるケースを考えてみましょう。
次は逆に、Uを5、Nを3として考えてみます。

こちらも途中までは、先ほどの例と同じく、再帰でCurrentに1が追加されていきます。

Current["length"]が3になったところで、最初にCurrent["length"] extends Uが評価されます。Uは5なので、これは満たされず、Current["length"] extends Nが評価されます。Nは3なので、これは満たされて、条件型はFalseを返します。

N < Uかを判定

type IsLessThan<
  N extends number,
  U extends number,
  Current extends number[] = []
> = Current["length"] extends N
    ? N extends U
        ? False
        : True
  : Current["length"] extends U
      ? False
      : IsLessThan<N, U, [...Current, 1]>;

N(整数)と U(整数)を比較し、N < Uの場合はTrueを、それ以外の場合はFalseを返します。
ここでの判定方法も、基本的にはIsMoreThanOrEqualToと同じで、再帰的にCurrentに1を積み上げていますが、UNと一致した場合、まずCurrent["length"]Nと一致するかが評価され、一致した場合、NUが一致するか評価され、一致した場合Falseが返るようになっています。

年齢に応じた割引金額を決定

type UserDiscount<T extends number> = 
IF<
  IsLessThan<T, 18>,
  1000,
  IF<
    IsMoreThanOrEqualTo<T, 65>,
    1500, 
    0
  >
>;

これまでに定義した型を用いて、年齢(T)に応じて割引金額を決定しています。

type typecheck17 = UserDiscount<17>; // 1000
type typecheck18 = UserDiscount<18>; // 0
type typecheck17 = UserDiscount<44>; // 0
type typecheck18 = UserDiscount<45>; // 1500

このようにTypeScriptの型は高い表現力を持っていることがわかります。

以上、型レベルプログラミングの紹介でした。

明日は@Yuma-Satakeさんによる「お前は今まで書いた as any の数を覚えているのか」です。

TSKaigi 2025の告知

2025年5月23日/24日にTSKaigi 2025が開催されます!
TSKaigiは日本最大級のTypeScriptをテーマとした技術カンファレンスです(前回の参加者2000人以上)
TypeScriptに興味のある方は、ぜひ公式サイトやXを確認してみてください。
TSKaigi 2025 ティザーサイト:https://2025.tskaigi.org/
公式サイト:https://tskaigi.org/
X:https://x.com/tskaigi

参考

GitHubで編集を提案

Discussion