TypeScript中級者向け!型レベルプログラミングの紹介
こちらは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 B
はA ⊂ 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 B
とB 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
のように書きます。これはT
がU
の部分集合である場合、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]>;
型N
とU
を比較し、N >= U
の場合にTrue
を、それ以外の場合にFalse
を返します。
再帰構造を持つため少し複雑なので、図を示して説明します。
この型は、Current
というnumber
の配列の型を持ちます。
Current
は初期状態は空っぽです。そのためCurrent["length"]
は0です。
まず初めに、Current["length"] extends U
でCurrent["length"]
がU
の部分集合か判別しています。
ここからは、N >= U
がTrue
になるケースを考えてみましょう。
例えば、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 U
とCurrent["length"] extends N
つまり、1 === 3
と1 === 5
は満たされず、次の再帰に移ります。その結果、Current配列型に1が追加され、Current["length"]
は2になります。
ここでも、Current["length"]
は2のため、Current["length"] extends U
とCurrent["length"] extends N
つまり、2 === 3
と2 === 5
は満たされず、次の再帰に移ります。その結果、Current配列型に1が追加され、Current["length"]
は3になります。
ここでは、Current["length"]
が3のため、Current["length"] extends U
つまり、3 === 3
が満たされ、条件型はTrue
を返します。
ここからは、N >= U
がFalse
になるケースを考えてみましょう。
次は逆に、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を積み上げていますが、U
がN
と一致した場合、まずCurrent["length"]
がN
と一致するかが評価され、一致した場合、N
とU
が一致するか評価され、一致した場合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
Discussion