Y.Hoshiをフォローする

TypeScriptの型レベルプログラミング

フロントエンド

みなさん、TypeScriptやっていますか?
今回はTypeScriptの「型レベルプログラミング」にフォーカスした内容になっています。
TyepScriptの型付けで周りより一歩先に進みたいあなたはぜひご覧になってください👋

型レベルプログラミングについて

TypeScriptでの型レベルのプログラミングについて

TypeScriptの型定義というとどんなものを思い浮かべますでしょうか?
まず、一番最初に思い浮かぶであろう最もシンプルな例は以下のようなinterface型によるデータ構造の定義になるかと思います。

TypeScript
interface User {
  name: string;
  age: number;
  job: string;
}

TypeScriptに少し慣れてくると、より込み入った型定義をしたくなってきますよね。
そんな時に役に立つのが組み込みの各種ユーティリティ型です。
こうした型を利用することで、より複雑な型の定義が可能になってきます。

TypeScript
type PartialUserInfo = Pick<User, "name" | "age">; 

//-> type PartialUserInfo = {
//      name: string;
//      age: number;
//   }

ところで、TypeScriptの内部実装的には、こうしたユーティリティ型は通常の型定義の中にロジックを組み込むことで実現されています。
例として取り上げたPick型の内部実装をのぞいてみると、このようになっています。

TypeScript
/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

このような型に対してのプログラミングを行えることは、TypeScriptの魅力的な機能のひとつとなっています。
今回は、こうしたTypeScriptの型レベルでのプログラミングにフォーカスしてみていきましょう。

型レベルプログラミング概要

表題に掲げている「型レベルプログラミング」という言葉にあまり聞きなじみがない、という方も多いかと思います。
型レベルプログラミングについては、HaskellやTypeScriptなどの文脈で語られることが多い概念で、「型定義の中にロジックを組み込む」ようなプログラミング手法を指しているもののようです。
また、そうすることにより以下のような目的を達成しています。

  • 型を関数のように利用して新しい型を作成する
  • 型定義をロジックとして落とし込むことにより、汎用的でより細かい型定義が可能になる
  • 要件により即したロジックを型に盛り込むことでコードの静的安全性を高める

ただし、色々ドキュメントをあたってみたのですが、「型レベルプログラミング」というものについての厳密・明確な定義というのは見つけることができませんでした。
(とはいえ、色々なソースがあるので概念として存在してはいるようです。)

そのため、本稿では「型定義の際に、静的型定義以外のロジックを含むもの」をまとめて「型レベルプログラミング」と表記することにします。

本稿での目的

誤解のないようにお伝えしておくと、本稿の目的は「型レベルプログラミングをどんどん使おう!複雑な型を作ろう!」と斡旋することではありません。
TypeScriptの型レベルプログラミングを通して以下のような技術を身につけ、レベルアップを図ろうということがねらいです。

  • 誤ったコーディングを未然に防ぐための仕組みを構築することができる
  • 要件・仕様などに対して、より論理的にロジックと型を紐づけることができる
  • 型レベルでのプログラミングを学ぶことにより、型付の理解を深め、知見と視野を広げる

では、始めましょう!

TypeScriptの型レベルプログラミング基本要素

TypeScriptの型定義においては、通常のロジックの記述とは異なり、使うことのできる文法が限られてきます。
まずは、私たちが使うことができるTypeScriptの機能、あるいは文法概念について確認しましょう。

分岐処理

TypeScriptでは、与えられた型に応じて条件分岐を行いたいケースでは「Conditional型」を利用します。
Conditional型は、「T extends U ? X : Y」 のように三項演算子のような記法で記述し、Tの型に応じてXあるいはYの型を返すことができます。

TypeScript
type StrOrNever<T> = T extends string ? string : never;

type TestStrOrNever1 = StrOrNever<"test">; // -> string
type TestStrOrNever2 = StrOrNever<10>;     // -> never

代入処理

「infer」は、TypeScriptにおいて引数に与えた型の推論を行う目的で利用される文法要素です。
以下はTypeScriptに標準で備わっているReturnType型の実装例です。

TypeScript
type ReturnType<T> = T extends (...args: any[]) => infer T ? T : any;

const testFunc = (arg1: number, arg2: string) => {
  return `${arg1.toString()}+${arg2}`;
};
type TestReturnType = ReturnType<typeof testFunc>;  // -> strin

ジェネリックとして与えられた型Tに対し「何らかの関数のサブタイプであるか否か」を判定しています。
関数のサブタイプである場合は「infer T」によって推論された関数の返り値の型を返し、サブタイプでない場合はneverを返す実装となっています。

「infer T」のようにinferの後に型を記述するとT型が変数のように扱われ、以降のロジックで使いまわすことができます。

パターンマッチング

値に対してパターンによるマッチングをかけたい場合には「Tuple」と「TemplateLiteral型」を利用できます。

TemplateLiteral型はTypeScriptの機能としては比較的新しいものです。
具体的には、通常のJavaScriptのテンプレートリテラルと同じ構文で型を生成することができるという型です。

TypeScript
type Fruits = "apple" | "grape";

type MyFruits = `my_${Fruits}`; // -> "my_apple" | "my_grape";

また、先ほどのConditional型を併用することによって、型の互換性を推論することも可能となっているため、以下のようなコードで特定のリテラル型の一部を切りだしたリテラル型を定義することもできます。

TypeScript
type ExtractString<T> = T extends `Head ${infer Tail}` ? Tail : '';

type TestExtractString = ExtractString<"Head Tail">; // -> "Tail"

同様に、TupleとConditional型を組み合わせることによって、分割代入のような記法でTupleの一部をマッチングして切りだすような使い方が可能です。

TypeScript
type ExtractFirst<T> = T extends [infer First, ...infer _] ? [First] : [];
type ExtractSecond<T> = T extends [infer _1, infer Second, ...infer _2] ? [Second] : [];
type ExtractLast<T> = T extends [...infer _, infer Last] ? [Last] : [];

type TestExtractFirst = ExtractFirst<[1,2,3]>;   // -> 1
type TestExtractSecond = ExtractSecond<[1,2,3]>; // -> 2
type TestExtractLast = ExtractLast<[1,2,3]>;     // -> 3

反復処理

最後に、反復処理です。
TypeScriptの型定義においては文(Statement)を記述することはできず、反復処理を表現する直接的なキーワード(for, while等)も存在しません。
そこで、反復処理については「型の再帰的な定義」によって実現します。

以下は、タプルの長さを返す型の実装例です。

TypeScript
type Len<T, Result extends number[] = []> = T extends [infer _, ...infer Tail]
  ? Len<Tail, [1, ...Result]>
  : Result['length'];

type TestLen1 = Len<[1,2]>;       // -> 2
type TestLen2 = Len<[1,2,3,4,5]>; // ->5

以上が、本稿で使用しているTypeScriptの型付けにおける文法要素となります。

既にお気づきの方もいらっしゃるかもしれませんが、これらの文法要素は「順次・反復・分岐」というプログラミングの3大要素が含まれています。
そのため、オランダの計算機科学者 E. J. Dijkstraの理論に則れば、「(理論上は)あらゆるロジックが記述可能なはず」だといえるでしょう(ニッコリ)

実際、Typescriptの型のみで言語のインタープリターを作成している方もいらっしゃるようなので、興味のある型はぜひ参考になさってみてください。

TypeScriptでの実装例

本稿で取り上げる実装例として、「文字列を”,”区切りの各要素のユニオン型として取得する」型定義を実装します。

TypeScript
// "," 区切りの文字列を...  "," で分割した文字列リテラルのユニオン型として取得する
"id, name, age, job"     ->  "id" | "name" | "age" | "job"

以前IndexedDBを取り扱うパッケージを利用する際に、テーブルのインデックスを指定する方法が「”,”区切りの単純な文字列で指定するだけ」だったのですが、「実装漏れがあった場合に静的に解析できなくて不便だなぁ。」感じたという経験がモチベーションとなっています。

TypeScript
// friendsテーブルを作成しインデックスとして"++id", "name", "age"を指定する
db.version(1).stores({
  friends: '++id, name, age'', // インデックスの指定方法が","区切りの文字列しかない...
});

db.friends
  .where('age').above(25)
  .orderBy('mame') // インデックスに指定されていない値を引数に渡した

今回の型定義によって、「”,”区切りの文字列という型として意味を持たない情報」から、「インデックスとしてとりうる文字列の種類を静的な型へ変換」することが可能となるため、実装時のタイプミスやインデックスに指定できない文字列を指定するといったミスを未然に防ぐことができます。
(ちなみに、このような場合はそもそもデータの持ち方を改善すれば済む場合もあります。今回はあくまで練習として複雑な型を定義しています。)

参考として、通常のJavaScriptコードで実装を再現するならば以下のようなイメージとなります。

TypeScript
const extractKeys = (line) => {
    return line.split(",")                 // 文字列を","で分割
        .map((chunk) => {
            return chunk.trim();           // 分割した各文字列をトリム
        });
};
console.log(extractKeys("id, name, age")); // -> ["id", "name", "a

これを、型として表現してみましょう!
実装の方針としては、以下の流れで行っていきます。

  1.  文字列を指定文字列区切りで分割する型の実装
  2.  文字列の中からスペース等余分な文字列を削除する型の実装
  3.  1, 2を組み合わせて最終的な型の実装

文字列を指定文字列区切りで分割するSplitBy型の実装

SplitBy型

SplitBy型は文字列を指定した文字で分割する型です。
文字列に対してパターンマッチを行い、「”マッチした文字列+それ以降”で分割、タプルに格納するという処理を再帰的に呼び出し」ます。
最終的に、オリジナルの文字列を指定文字で分割したタプルが得られます。

TypeScript
type SplitBy<
  String extends string,
  By extends string,
  Result extends string[] = [],
> =
  String extends `${infer First}${By}${infer Tail}`
    ? SplitBy<Tail, By, [...Result, First]>
    : String extends By
      ? Result
      : [...Result, String];

type SplitByComma<T extends string> = SplitBy<T, ",">;

type TestSplitByComma1 = SplitByComma<"Test">;            // -> ["Test"]
type TestSplitByComma2 = SplitByComma<"key1,key2,key3">;  // -> [

スペース等余分な文字列を削除するTrim型の実装

Trim型の実装にはひと手間必要です。
というのも、文字列のトリムを行うためには文字列の先頭と末尾それぞれをマッチングして、削除対象文字列であれば切り捨てるようにしたいのですが、文字列のパターンマッチングでは末尾文字列をマッチングする直接的な方法がありません。

そこで、今回は「いったん文字列を1文字ずつに分解したタプルにし、タプルの先頭と末尾をマッチングすることで同等の処理を行う」という流れで実装しています。
複数の型を組み合わせることになるため、少し定義が長くなります。

Chars型

文字列を1文字区切りで分割したタプルに変換する型です。
シンプルなので、型定義の練習としてちょうどいい感じです。

TypeScript
type Chars<
  String extends string,
  Result extends string[] = [],
> =
  String extends `${infer First}${infer Tail}`
    ? Chars<Tail, [...Result, First]>
    : Result;

type TestChars = Chars<"aaaaa">; // -> ["a", "a", "a", "a", "a"]
JoinWith型 JoinWithEmpty型

複数文字列のタプルを指定文字列で結合する型です。
ちょっぴり処理が複雑になります。

TypeScript
type JoinWith<
  Strings extends string[],
  With extends string,
  Result extends string = "",
> =
  Strings extends [
    infer First extends string,
    ...infer Tail extends string[],
  ]
    ? JoinWith<
        Tail,
        With,
        Result extends "" ? First : `${Result}${With}${First}`
      >
    : Result;

type JoinWithEmpty<Strings extends string[]> = JoinWith<Strings, "">;

type TestJoinWith = JoinWith<["1","2","3","4","5"], ".">;       // -> "1.2.3.4.5"
type TestJoinWithEmpty = JoinWithEmpty<["1","2","3","4","5"]>;  //
OmitIgnore型

タプルの両端に対して指定された文字列であれば除外する処理を行う型です。
_OmitIgnore型が本稿の型定義におけるピークだといえると思います。

TypeScript
type _OmitIgnore<
  Chars extends string[],
  Ignore extends string,
  Result extends string[] = [],
> =
  Chars extends [
    infer First extends string,
    ...infer Middle extends string[],
    infer Last extends string,
  ]
    ? First extends Ignore
      ? Last extends Ignore
        ? _OmitIgnore<Middle, Ignore>
        : _OmitIgnore<[...Middle, Last], Ignore>
      : Last extends Ignore
        ? _OmitIgnore<[First, ...Middle], Ignore>
        : Chars
      : Result;

type _TestOmitIgnore = _OmitIgnore<["\n", "\n", "a", "\n", "b", "c", "\n"], "\n">; // -> ["a", "\n", "b", "c"]

type OmitIgnore<
  String extends string,
  Ignore extends string,
> = JoinWithEmpty<_OmitIgnore<Chars<String>, Ignore>>;

type TestOmitIgnore = OmitIgnore<"   Test   ", " ">; // -> "
Trim型

OmitIgnore型によってスペース(” “)をトリムする型です。

TypeScript
type Trim<String extends string> = OmitIgnore<String, " ">;

type TestTrim = Trim<"    TrimTest   ">; // -> "TrimTest"

用意した複数の型を組み合わせたExtractKeys型の実装

ExtractKeys型

最後に、これまでに定義したTrim型、SplitWithComma型を駆使して最終的な型定義を完了させます。
SplitByComma型で”,”分割したタプルを[number]でユニオン型に変換しているところがポイントです。

また、Typescriptではユーティリティ型などにユニオン型を渡した場合に、ユニオン型の各要素に対してユーティリティ型が適用されます。
この機能はUnionDistributionと呼ばれ、これも型定義の際に便利な機能となっています。

TypeScript
type ExtractKeys<String extends string> = Trim<SplitByComma<String>[number]>;

type Keys = "id, name, age, job";
type TestExtractKeys = ExtractKeys<Keys>; // "id" | "name" | "age" | "job"

型レベルプログラミングの細かいテクニック

本稿で定義した型の中で、いくつか細かなテクニックを利用しました。
Typescriptの機能の話から少し逸脱した内容ですが、こうしたこともできるよーというのだけ感じてもらえればと思います。

末尾再起(最適化)

本稿の型定義例ではいくつか再帰的な型を定義しているのですが、それらの定義はすべて「末尾再起」となっています。
(末尾再起の定義については、調べれば色々なソースがあるのでそちらに譲ることにします。興味のある型はぜひご覧ください。)

末尾再起による最適化処理は、関数型プログラミングにおいて再帰的な関数定義によるスタックオーバーフローの可能性を回避するための一般的な解決策なのですが、Typescriptの型定義においても、末尾再起による最適化の恩恵を受けることができます。

たとえば今回の例でいうと、文字列を1文字ずつ分割するChars型は以下のようにシンプルな再帰型として定義することも可能です。

TypeScript
type CharsSimple<String extends string> =
  String extends `${infer First}${infer Tail}`
    ? [First, ...CharsSimple<Tail>]
    : [];

type TestCharsSimpleShort = CharsSimple<"short text">; 
type TestCharsSimpleLong = CharsSimple<"some long text......">; 

しかし、Typescriptにおいては再帰的型定義の回数制限(40~50階層程度)がされているため、この型定義の場合は40文字程度で型定義のエラーが発生します。
一方で、本稿で定義したChars型の場合は末尾再起による記述を行っているため、1000階層程度の再帰が実現できるようになっています。

型の部分適用

複数引数をとる関数に対して、一部の引数のみを適用した新しい関数を定義することを「部分適用」といいます。
こちらの記事で部分適用について詳しく取り上げているので、気になる型はぜひご参照ください。

型の定義においても、ジェネリクスを引数のように利用することができるため、複数のジェネリクスをとる汎用的な型を定義し、一部のジェネリクスに固定の型を適用することでより特化した型を生成するというアプローチが可能です。

今回の例でいうと、SplitBy, JoinWitのような汎用的な型を定義して、それらに部分適用を行ったSplitBySpace, JoinWithEmptyのようなより特化した型を定義するということを行っています。

TypeScript
// 汎用的なJoinWith型に部分適用してより特化した型にする
type JoinWithComma<T extends string> = JoinWith<T, ".">;
type JoinWithSpace<T extends string> = JoinWith<T, " ">;
type JoinWithBreak<T extends string> = JoinWith<T, "\n">;
......

まとめ

  • Typescriptの型付けではいろいろなロジックを記述することができる。
  • 型レベルプログラミングに慣れることで、実装時のミスを未然に防ぐ高度な静的型付けを実現できるかも?

Typescriptの型付では、型の定義で様々なロジックを使って定義することができるのでとても自由度の高い型付けができます!
こうした型付けに慣れることで、プロジェクトのデータ構造等により適した型付けができるように心がけていきたいですね。(実際のプロジェクトでこういう型付けをしているわけじゃないですが)

余談ですが、今回作成したような型を組み合わせてSQLを静的に解析できる型を作れないかと妄想しています。🤤
とはいえ、むやみに難しい型を作るのではなく、あくまで節度のある型付をしましょう。