型レベルプログラミング入門

2022/09/19に公開

簡単な関数で、「関数の戻り値と型レベルの戻り値の完全一致を目指す」ということを題材に、型レベルプログラミング入門者向けの記事を書いてみました。

今回作ってみたplaceholders関数は、整数値を受け取って以下のような文字列を生成します。


placeholders(20);をVSCodeでホバーしている様子です。つまりplaceholders(20)の計算結果が型情報としてエディター上で見える、ということです。

この関数の実装+テストはこちら

先に前置きしておくと、実務でplaceholdersを実装する場合、単体テストとJSDocを書いておけばバッチリだと思います。わざわざ型レベルプログラミングしません。ただ、型レベルプログラミングの題材として難易度がちょうどいい、と思ったから型レベルプログラミングしてみただけです。

ということで記事ではplaceholders関数を分解して、すごく丁寧に型レベルプログラミングの考え方、やり方を解説していきます。TypeScriptを開発で使っているけど、型レベルプログラミングは聞いたことない、という人でも理解してもらえるよう説明したいと思います。きっとこの記事を最後まで読んだらこれまでよりも一歩踏み込んだ型が書けるようになっていることでしょう。

モチベーション

これを作った背景を簡単に説明しておくと、現在のプロジェクトではNode.jsからPostgresにnode-postgresのpgでクエリーを投げています。pgは$1, $2といった文字列をプレイスホルダーとして、第1引数にプレイスホルダーを含むクエリーのテンプレート、第2引数にプレイスホルダーを置換する値の配列を渡します。

ストアドファンクションの引数のプレイスホルダーが30近く列挙されていて「$1,$2,$3,$4,$5,...(これミスあっても見落とすやろ)」と思ったのがきっかけです。

型レベルプログラミングの基本方針(初心者向け)

TypeScriptの型レベルプログラミングで使えるツールはそれほど多くありません。今回扱うツールは以下の通りです。

概念 型レベルプログラミングのツール
ループ 再帰
条件分岐 コンディショナル型
分割代入、残余代入 infer
文字列 テンプレートリテラル型
数値 タプル型のlength

型の構造を考えるために再帰関数と三項演算子で実装する

型レベルプログラミングに慣れるまでは、まず関数の実装を考えましょう。

型レベルではループが使えないので再帰関数として実装します。またコンディショナル型は三項演算子を型に持ち込んだ文法なので、型をイメージしやすいように三項演算子を使います。構造を考えるための実装が出来たら、それを型に置き換えるのはすごく簡単です。もちろん慣れてきたら直接型の実装が書けるようになります。

数値リテラル型はタプル型のlengthで型を導く

型レベルプログラミングでは数値リテラル型を直感的に扱える方法は用意されていません。数値リテラル型を扱うにはタプル型を経由させる必要があります。タプル型のlengthプロパティーはタプルの要素数の数値リテラル型なので、その型を使います。

文字列を考えるときはテンプレートリテラル型を使います。テンプレートリテラル型の一般的な解説は他のサイトに譲りたいと思います。

Placeholders型のパーツを実装する

まずは型レベルで考える準備として関数を実装してみます。

const placeholders = (count: number) =>
  [...Array(count).keys()].map((i) => `$${i + 1}`).join(',');

ワンライナーで書くならこんな感じですね。上記の関数の型はそのままだと次のようになります。

type Placeholders = (count: number) => string;

「何かしら数値を受け取って、何かしら文字列を返す」ということが分かります。関数名やコメントがあればこれで十分かもしれませんが、今回は型レベルプログラミングで実際の結果と型を100%一致させることが目標です。

この実装をよく観察すると、型レベルプログラミングするにあたって次の型宣言が必要になることが分かります。

型宣言 対応部分 難易度
Sequence [...Array(count).keys()] ⭐️⭐️⭐️
Mapping [...].map() ⭐️⭐️⭐️
Add1 i + 1 ⭐️⭐️⭐️
With$ $... ⭐️
Join [...].join(',') ⭐️⭐️

参考までに難易度を付けてみました。⭐️⭐️⭐️は型レベルプログラミング初心者には難しく感じるかもしれません。でも安心してください、しっかり解説します。

実際に開発ニーズのためにPlaceholders型を実装しようと思ったら、Placeholders型以外の型宣言は2~3つ程度で実装すると思います。しかし今回は型レベルプログラミング初心者向けの記事です。Unix哲学風に1つの型宣言で1つのことをやる方法で実装・解説します。

記事の締めくくりに、出来上がった型宣言をリファクタリングしてコンパクトにします。

Sequence型の宣言

順に解説していきますが最初から⭐️⭐️⭐️です。他の型宣言を考える上でも土台となるよう丁寧に解説します。

目標確認

[...Array(count).keys()]と対応する型宣言について考えます。整数を受け取ってタプルの型を返します。

type SequenceTest = Sequence<3>; // [0, 1, 2]

Sequence型の関数バージョン、sequence関数を実装する

[...Array(count).keys()]は、0から連続する数列となる配列を生成するイディオムのようなものですが、このコードの形は型宣言を想像するヒントになりません。まずは型宣言を想像するヒントになるよう再帰関数と三項演算子による実装を考えたいところです。

しかし、そもそも再帰関数初心者という方のためにまずはwhileループを使った実装を考えてみます。ただし数値は型による実装を考えやすいように配列のlengthの値を使います。

const sequence = (count: number): number[] => {
  // 結果を格納する配列を宣言
  const result: number[] = [];
  while (result.length !== count) {
    // 0, 1, 2 という順で格納される
    result.push(result.length);
  }
  // result.length === count でループを抜けて結果を返す
  return result;
};
    
sequence(3) // [0, 1, 2] 

sequence(3)の計算が進む様子は次の通りです。

回数 while文の条件式のresult.length push後のresult
1 0 [0]
2 1 [0, 1]
3 2 [0, 1, 2]

[0, 1, 2]まで要素が追加されるとresult.length === countとなり、[0, 1, 2]が戻り値です。

この実装を再帰関数で書き換えますが、2つポイントがあります。

まず上記コードのreusltconstで宣言されていますが、これは再代入を禁止しているだけで計算過程では配列の内容がどんどん変化しています。今回は変化していく値を引数として扱うことで同じ役割を実現します。再帰関数でこのアイディアはよく登場します。

次に私は型レベルプログラミング以外ではEarly Returnするスタイルを徹底していますが、型レベルプログラミングではブロック文({...})はないので型をイメージする準備としての関数もreturnを使わずに書きます。

これらのことを踏まえてSequence型の関数バージョン、sequence関数は次のようになります。

const sequence = (count: number, result: number[] = []): number[] => 
  count === result.length
    ? result
    : sequence(count, [...result, result.length]);
    
sequence(3) // [0, 1, 2] 

模範解答

これで準備は整いました。手が動かせそうな方はぜひ自分でチャレンジしてみてください。ブラウザーでTypeScriptを試せる無料のPlaygroundがあるので、この記事を読みながら試すのにオススメです。

Sequence型の答えはこちらです。
type Sequence<Count extends number, Result extends number[] = []> =
  Result['length'] extends Count
    ? Result
    : Sequence<Count, [...Result, Result['length']]>;

type SequenceTest = Sequence<3>;
// type SequenceTest = [0, 1, 2]

こちらのプレイグラウンドで確認出来ます。

どうでしたか?おそらく再帰のプログラムを自分で書ける方は、型レベルでも書けたのではないでしょうか?

準備で書いた再帰のプログラムとSequence型をよく観察してみてください。気持ちがいいくらい構造が同じだということに気がつくと思います。このことに気がついてしまうと、型レベルプログラミングのハードルはグッと下がると思います。

Mapping型の宣言

次はmapの型バージョン、Mappingの型宣言について考えていきます。Mapという名前はMapオブジェクトがあるので使えません。ということでMappingと命名します。

目標確認

まずmap()メソッドの説明をMDNで確認しましょう。

map() メソッドは、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成します。

強調している「与えられた関数」というところが問題です。というか、この性質をTypeScriptの型システムに置き換えることは出来ません。この説明をそのまま型に適用すると次のような感じになるでしょうか。

type MappingTest = Mapping<[0, 1, 2], Add1With$>;

しかしこのような型宣言は成立しません。Add1With$は1つのジェネリクスを必要とする型のつもりで命名していますが、ある型にジェネリクスを必要とする型を渡す、という書き方が現在のところTypeScriptに用意されていないからです。そのため、Mappingの型宣言の内部でAdd1型とWith$型を使用するように実装します。

つまり、目指す型は次のように振る舞います。

type MappingTest = Mapping<[0, 1, 2]>;
// type MappingTest = ['$1', '$2', '$3']

Mapping型の関数バージョン、mapping関数を実装する

さて、次にこのmap()をメソッドではなく関数として実装してみます。繰り返しになりますが型レベルプログラミングではループは使えないので再帰関数で実装します。add1関数とwith$関数が実装済みだとして次のように実装できます。

const mapping = (arr: number[], result: string[] = []): number[] => {
  const [first, ...tail] = arr;
  return typeof first === 'number'
    ? mapping(tail, [...result, with$(add1(first))])
    : result;
}
    
mapping([0, 1, 2]); // ['$1', '$2', '$3']

arrfirsttailに分割代入、残余代入していますね。
この部分について型レベルプログラミングでは型演算子であるinferを使って次のように書きます。

Arr extends [infer First extends number, ...infer Tail extends number[]]
  ? {Firstがnumber型、Tailがnumber[]型の場合の型}
  : {その他の場合の型};

通常のコードでは分割代入・残余代入とtypeofによる型のチェックをまとめて書くことはできませんが、型レベルプログラミングの場合は同時に書くことができます。

今回もreturnを省略して書くことは可能ですが、そのためには引数で[first, ...tail]という書き方が必要になります。

const mapping = (
  [first, ...tail]: number[],
  result: string[] = []
): number[] =>
  typeof first === 'number'
    ? mapping(tail, [...result, with$(add1(first))])
    : result;
    
mapping([0, 1, 2]); // ['$1', '$2', '$3']

mapping([0, 1, 2])の計算が進む様子は次の通りです。

呼び出し回数 first tail result
1 0 [1, 2] []
2 1 [2] ['$1']
3 2 [] ['$1', '$2']
4 undefined [] ['$1', '$2', '$3']

先頭から一つづつ要素を変換していく様子が理解できるかと思います。

模範解答

これで準備体操は十分です。Mappingの型宣言を考えてみましょう。詰まった時はmapping関数の実装を参考にしてください。

Mapping型の答えはこちらです。
type Mapping<Arr extends number[], Result extends string[] = []> =
  Arr extends [infer First extends number, ...infer Tail extends number[]]
    ? Mapping<Tail, [...Result, With$<Add1<First>>]>
    : Result;
    
type MappingTest = Mapping<[0, 1, 2]>;
// type MappingTest = ['$1', '$2', '$3']

今回は分割代入、残余代入を型レベルで記述するためにinferを使いました。難しい型を書こうとするとinferを使いこなせることが必須です。ぜひ使いこなせるようになりましょう。
まだAdd1型とWith$型がないのであとで確認しましょう。

Add1型の宣言

おそらくAdd1型の実装が初心者にとって最もギャップがあると思います。型レベルプログラミングでは数値リテラル型の加算、減算のためにタプル型のlengthを経由するからです。知らないとなかなか出てこない発想だと思います。

目標確認

まずば目標の確認です。

type Add1Test = Add1<2>;
// type Add1Test = 3

Add1型の関数バージョン、add1関数を実装する

何も考えずにadd1関数を実装するならこうですね。

const add1 = (value: number) => value + 1;

しかし、これではvalue + 1という部分が型宣言のヒントになりません。代わりに配列のlengthを変数として考えて実装を組み立てます。

const add1 = (value: number, result: number[] = []): number =>
  result.length === value
    ? [...result, 0].length
    : add1(value, [...result, 0]);

add1(3); // 4

result配列を伸ばすために0を格納していますが、どんな値でも大丈夫です。 add1(3)の計算が進む様子は次の通りです。

呼び出し回数 value result [...result, 0].length
1 3 [] 1
2 3 [0] 2
3 3 [0, 0] 3
4 3 [0, 0, 0] 4

模範解答

関数実装 => 型宣言という変換はこれで3回目です。そろそろ手順が分かるようになった頃でしょうか。ぜひ手を動かしてから解答を確認してみてください。

Add1型の答えはこちらです。
type Add1<Value extends number, Result extends number[] = []> =
  Result['length'] extends Value
    ? [...Result, 0]['length']
    : Add1<Value, [...Result, 0]>;

type Add1Test = Add1<2>;
// type Add1Test = 3

こちらのプレイグラウンドで確認出来ます。

With$型の宣言

はい、最も簡単な⭐️の型宣言です。Template Literal Typesが理解出来ていれば即答の問題です。

目標確認

type With$Test = With$<1>;
// type With$Test = '$1'

模範解答

With$型の答えはこちらです。
type With$<Value extends number> = `$${Value}`;
type With$Test = With$<1>;
// type With$Test = '$1'

もし難しいという方はTemplate Literal Typesについて調べてみてください。

それではWith$の確認も兼ねて、Mapping、Add1、With$をこちらのプレイグラウンドで確認してみてください。

Join型の宣言

これで最後のパーツです。join()は配列を連結して1つの文字列を返します。そのままjoin()を使っても型レベルプログラミングのヒントにならないので、再帰関数として実装します。
['$1', '$2', '$1']

目標確認

次のように機能する型を宣言します。

type JoinTest = Join<['$1', '$2', '$3'], ','>;
// type JoinTest = '$1,$2,$3';

今回のPlaceholders型を作るという目標から考えるとJoin型がセパレーター文字列を受け取れるようにする設計は必須ではありませんが、join()メソッドの仕様を忠実に型で再現したいと思います。

Join型の関数バージョン、join関数を実装する

これまでに紹介してきた再帰関数の実装を応用することできっとjoin関数を実装できます。ぜひ力試しにプレイグラウンドでチャレンジしてみてください。

join関数の答えはこちらです。
const join = (
  arr: string[],
  separator: string,
  result = ''
): string => {
  const [first, ...tail] = arr;
  return typeof first === 'string'
    ? join(
        tail,
        separator,
        result === '' ? first : `${result}${separator}${first}`
      )
    : result;
}

模範解答

Join型の答えはこちらです。
type Join<
  Arr extends string[],
  Separator extends string,
  Result extends string = ''
> =
  Arr extends [
    infer First extends string,
    ...infer Tail extends string[]
  ]
    ? Join<
        Tail,
        Separator,
        Result extends '' ? First : `${Result}${Separator}${First}`
      >
    : Result;

こちらのプレイグラウンドで確認出来ます。

パーツを組み立ててPlaceholders型を実装する

いよいよ準備は整いました。
Placeholders型の実装にチャレンジしてもらうプレイグラウンドはこちらです。ぜひチャレンジしてみてください。

Placeholders型の答えはこちらです。
type Placeholders<T extends number> = Join<Mapping<Sequence<T>>, ','>;

パーツが出来上がっているので一瞬ですね。こちらで確認出来ます。

よりコンパクトにPlaceholders型を実装する

ここまで理解しやすさを最優先に1つの型で1つのことをする、という設計で実装を進めました。これはこれで1つ1つの型は非常にコンパクトなのですが繰り返しタプルを扱うあたりに冗長さを感じます。

内容
Sequence タプル型 => 数値リテラル型
Add1 数値リテラル型 => タプル型 => 数値リテラル型

数値リテラル型を扱うときにはタプル型を経由させるので、目的の数値リテラル型が手に入るまではタプル型のままにしておいた方が短く記述できます。Sequence型にAdd1型とWith$型の変換まで含め、Mapping型を不要にしてしまいます。

type Sequence<Count extends number, Result extends string[] = []> =
  Result['length'] extends Count
    ? Result
    : Sequence<Count, [...Result, `$${[0, ...Result]['length']}`]>;

type SequenceTest = Sequence<3>;

type Join<
  Arr extends string[],
  Separator extends string,
  Result extends string = ''
> =
  Arr extends [
    infer First extends string,
    ...infer Tail extends string[]
  ]
    ? Join<
        Tail,
        Separator,
        Result extends '' ? First : `${Result}${Separator}${First}`
      >
    : Result;

type JoinTest = Join<['$1', '$2', '$3'], ','>;

type Placeholders<T extends number> = Join<Sequence<T>, ','>;

type PlaceholdersTest = Placeholders<3>; // "$1,$2,$3"

かなり短くなりましたね。こちらのプレイグラウンドで確認出来ます。

おわりに

小さなパーツとして型を宣言する方法を解説し、それらを組み合わせた型の完成までを一緒に見てきました。Placeholders型では次のツールを使うための考え方を学びました。

概念 型レベルプログラミングのツール
ループ 再帰
条件分岐 コンディショナル型
分割代入、残余代入 infer
文字列 テンプレートリテラル型
数値 タプル型のlength

今回は題材の性質上、次のようないくつかの重要な概念とツールが紹介出来ていません。時間があるときにまた記事を書きたいと思います。

  • オブジェクト
  • 交差型,共用体型

「再帰と三項演算子のみによる関数の構造と、型レベルで同じ目的の型宣言の構造が同じになる」ということはどこかに書いてあったわけではありません。私が学んでいて気がついたことです。型レベルプログラミングの先駆者の方々はおそらく網羅的にドキュメントを読み、そのような関係は気にしないまま型レベルプログラミングで使えるテクニックの範囲を正確に把握しているのかなと想像していますが、私にとっては目から鱗でした。初心者の方にはこれを知っておくことで一気に上達すると思います。この「目から鱗」の感覚を多くの人に共感してもらえたら嬉しいです。

Discussion