TypeScriptで配列を引数に取り配列を返す関数を型定義する
はじめに
TypeScriptで引数と返り値が配列となる関数に型付けする方法についてまとめる。この記事の内容は先日Qiitaに書いた以下の記事がベースになっているが、あちらは最終的な結果から逆算して必要最低限で書いたところがあった。なので、こちらではもう少し初歩の内容から始めて、色々書いていきたいと思う。
TypeScriptで配列を引数に取り配列を返す関数を型定義する
配列の要素がプリミティブ型の場合
まず初歩の内容として、string[]
型の配列を引数に取り、そのまま string[]
型の配列を返すような関数を考える。この関数は以下のように型定義できる。
本記事では引数の配列を
beforeList
, 返り値の配列をafterList
と名付け、関数の名前をconvert
としている。
function convert(beforeList: string[]): string[];
これは配列の記法を知っていれば書けるのでなんてことはないと思う。では次に配列の要素が複数の型を取りうる場合について考える。
配列の要素を複数のプリミティブ型に対応させる場合
複数の型に対応することを考えると、自分がどういう型を定義したいのかもう少し詳細を詰める必要がある。例えば、「引数の配列の要素は string
型でも number
型でも良く、返り値の配列には引数と同じ型をつける」ということを考えたときに、以下の2つの場合が考えられる。
- 配列の要素に
string
型とnumber
型が入り乱れている場合 - 配列の要素は
string
型かnumber
型のどちらか一方である場合
配列の要素にstring型とnumber型が入り乱れている場合
これも初歩的な知識で書くことができる。
function convert(beforeList: Array<string | number>): Array<string | number>;
const afterlist = convert([1, 'a', 2, 'b']); // afterList は Array<string | number> 型になる
Union型を使うことで、string
型もしくは number
型という要素の型を表現できる。
配列の要素はstring型かnumber型のどちらか一方である場合
この場合は先程の型定義でも問題ないが、より良いのはジェネリクスを使って型定義する方法である。この例は先程の例とは異なり関数の使用時に要素の型を string
型もしくは number
型のどちらかに絞り込むことができるはずである。よって型でもそれを表現したい。ジェネリクスを使うことで、「string
型の配列を受けて string
型の配列を返す関数」と「number
型の配列を受けて number
型の配列を返す関数」を1つの型定義で表現することができる。そして、string
型なのか number
型なのか、の絞り込みはその関数が使用されるときに引数の型から推論する。引数から型を推論するために、ここでは引数の型を型変数 T
で受けることにする。
function convert<T>(beforeList: T): T; // 返り値は引数と同じ T 型とする
上の例では、先程まで string | number
で定義していた関数の引数を T
という型変数で受けている。T
が型変数であることを示すために、関数引数 ()
の前に <T>
という宣言を追加している。引数の型を T
という変数で受けたことで、返り値の型にその型情報を使うことができる。
これでこの convert
という関数は引数にどんな型でも取ることができ、それはそのまま返り値の型となった。しかし、この例でやりたかったことは string
型の配列、もしくは number
型の配列を受け取り、返り値としてそれぞれ引数と同じ型の配列を返すことだった。「引数にどんな型でも取ることができる」というのでは型として緩い。そこで型引数 T
に次のようにして制約を与える。
function convert<T extends string[] | number[]>(beforeList: T): T;
これにより、先程宣言した型引数 T
は extends string[] | number[]
という制約を満たさなければならなくなった。この extends
が意味するところは、T
型が string[] | number[]
の部分型かどうか、言い換えると、T
型を string[] | number[]
型に代入できるかどうか、を引数の制約にするということである。いま、引数に想定しているのは string[]
型、もしくは number[]
型なので、これらは string[] | number[]
の部分型という条件を満たしている。逆に、例えば T
に any
や string
が入ると、それらは string[] | number[]
の部分型ではないので、制約違反ということになり、型エラーになる。よって、これは狙い通りの制約である。
React のような完成されたライブラリを使っている限りは、このレベルの型定義が必要になる場面はまず無いので、この辺りがTypeScript型パズルの最初のハードルになると思っている。自分がそうだったけれど、型定義自体を目的に勉強しているうちはなかなか身につかず、手段として必要になったときに意外とすっと習得できたりする。
配列の要素にstring型とnumber型が入り乱れている場合の発展
ここからは前に考えた string
型と number
型が入り乱れている場合の発展として、その個々の要素の型情報を維持したまま返り値に型をつける方法を考える。これを実現するためには、これまでの配列型ではなく、Tuple型を使う必要がある。Tuple型に起因する制限として、これから定義する関数はプログラム実行時に動的に生成した配列を引数に渡すことはできない。ただ、コーディング時に要素数が確定している配列に対してはそれをTuple型で定義することで引数に渡すことができる。
TypeScriptのTuple型は配列型の特殊系で、要素数が固定されており、それぞれの要素の型が定義されたものと捉えることができる
このような型はVariadic Tuple TypesというTypeScriptの機能を使って以下のように書くことができる。
function convert<T extends ReadonlyArray<string | number>>(beforeList: [...T]): [
...{ [K in keyof T]: T[K] extends string ? string : number}
];
const afterList = convert([1, 'a', 1]) // afterList は [number, string, number] 型になる
上では convert
の引数の中で [1, 'a', 1]
と書いているので、これがTuple型と判断されている。Variadic Tuple Typesのポイントは 引数を [...T]
という型で受けることができている点である。つまり、引数は何かしらのTuple Tを展開したものであるとして、引数の要素全体を T
で受ける。受けた後は T
の各要素にインデックスアクセスすることができ、例のように extends
で stirng
の部分型かどうかを見て、返り値の型を出し分けることができる。
なお、引数のタプルを関数の外で定義する場合は以下のように書く。
const beforeList: [number, string, number] = [1, 'a', 1];
const afterList = convert(beforeList);
配列の要素がジェネリック型の場合
上の例をもう少し応用して、あるジェネリック型のTupleを引数に取り、型変数を引き継いだまま別のジェネリック型のTupleを返す関数を型定義することができる。これについては最初にも貼った以下の記事で書いているので、結果だけ書くことにする。
// サンプルとして Before<T> 型と After<T> 型を定義
type Before<T> = {
type: 'before',
value: T,
}
type After<T> = {
type: 'after',
value: T,
}
// convert の型定義 (関数本体の実装は省略)
function convert<T extends ReadonlyArray<Before<unknown>>>(beforeList: [...T]): [
...{ [K in keyof T]: T[K] extends Before<infer U> ? After<U> : never }
]
// テスト
const before1: Before<number> = {
type: 'before',
value: 1,
}
const before2: Before<string> = {
type: 'before',
value: 'string',
}
const afterList = convert([before1, before2]); // [After<number>, After<string>] 型になっている
Tuple型で定義できるもの、という制約はあるものの、JSライブラリの中にはオブジェクトの配列から別オブジェクトの配列を作る関数を提供しているものがあったりするので、そういう関数に型定義する場面ではこの方法は役に立つことがあると思う。