ここ最近 TypeScript でコードを書いていて自分なりに面白いなと思ったことや、疑問に感じていることとかをまとめている。
空の配列やオブジェクト
要素がない配列やオブジェクト型をどうしても利用したい場合がある。その際に以下のような型を使うと良い。
type EmptyArray1 = never[];
type EmptyArray2 = [];
type EmptyObject = { [key in any]: never };
EmptyObject
のキーの型を any
としているが、これは特にこだわりはない。
合わせて、次のように readonly
を付加しても良さそうだ。これで配列であれば push
などのメソッドが使用できないようになる。
type EmptyArray1 = readonly never[];
type EmptyArray2 = readonly [];
type EmptyObject = { readonly [key in any]: never };
1つ以上の要素がなくてはならない配列
先ほどとは逆に、「要素が1つ以上なくてはならない配列」という型も需要がありそうだ。通常の string[]
などの配列型だと、要素がある場合も要素が全くない場合も含まれてしまうから、専用の型があると望ましい。
先程の空の配列の型と TypeScript のユーティリティ型 Exclude<T,U>
を使って、 string[]
から空配列を「除外」しようとしてもできない。
type NonEmptyArray = Exclude<string[], []>; // -> string[]
ということで何か別のアプローチが必要そうだ。最近 X であったとある投稿を参考にして、空でない配列は次のように与えられる。
// (空配列を含む) 通常の配列
type StringList = string[];
// 要素が少なくとも1つ以上存在する配列
type NotEmptyArray = [string, ...string[]];
言うまでもないが、タプルと配列の展開を使って1以上を実現しているようである。
タプルの各要素に操作するような型定義
例えば [string, number]
という型に対して [() => string, () => number]
という型を返す、というような、タプルの各要素に対して一定規則で型を変換させるような「型を別の変換させる関数」の型を実装したいと考える。任意要素数のタプルが受け取れるものとする。
こうした型を定義するには、次のように要素を1つ1つ変換する実装を行う。
type ToFunc<Input> = (
// 要素が空の場合はそのまま
Input extends [] ? [] :
// 先頭から1つずつ要素を処理する
// 要素が1つだけで `Rest` が空であっても問題ない
Input extends [infer Item, ...infer Rest] ? [
( () => Item ),
...(
ToFunc<Rest> extends any[] ?
ToFunc<Rest> : []
)
] :
// `[T1, T2, ...]` のようなタプル型だけではなく、フラットな型に対しても対応できるようにしている
( () => Input )
);
ToFunc<Rest>
が配列だと認識されない
[infer Item, ...infer Rest]
にマッチする場合において、 Rest
は残りの要素を指しており、本当は次のように単純に ToFunc<Rest>
とするだけでも問題なさそうである。
Input extends [infer Item, ...infer Rest] ? [
( () => Item ),
...ToFunc<Rest>
]
実際、次のようにエディタでは意図した通りに型の変換が行われる。
しかし画像でもエラーが出ているように、実際には ToFunc<Rest>
の部分で A rest element type must be an array type.
というエラーによりコンパイルエラーとなる。
Rest
は明らかに配列で、 ToFunc<Rest>
も実装を踏まえると配列だと分かるのにどうしてこうなるんだろうか、よく分かっていない。
この問題を解決するために、 ToFunc<Rest> extends any[] ? ToFunc<Rest> : []
と確実に配列型とみなされるように修正している。
関数の引数や戻り値の型
TypeScript に標準で用意されているユーティリティ型 Parameters
, ReturnType
を使って関数の引数や戻り値の型を取ることができる。
// こんな関数があった時に...
const descVec3 = (x: number, y: number, z: number) => {
if ( (x === 0) && (y === 0) && (z === 0) ) {
return null;
}
return `(${x}, ${y}, ${z})`;
};
// 引数と戻り値の型は
type Vec3 = Parameters<typeof descVec3>;
// Vec3: [x: number, y: number, z: number]
type Desc = ReturnType<typeof descVec3>;
// Desc: string | null
引数はタプル型として扱われる。
タプル型の各成分のラベル
関数の引数から取り出したタプル型では x: number
のように x:
というラベルが付されていた。このようにタプルの成分にはラベルを付けることができる。
type Person = [name: string, age: number];
オブジェクト型でも十分ではあるが、上記の例のようにタプルを使ってもそれぞれの成分の意味を与えることができるのは有用だと考えられる。 React の useState
関数の戻り値もこんな風に明示してくれないかな。
ただ残念なことに、このようなラベルを付けたところで、このラベルを使って値にアクセスすることはできない。
オーバーロードした関数型
TypeScript では次のようにオブジェクト型の定義で論理和型にすることはできる。
type Contact = (
| { company?: false; name: [first: string, last: string]; }
| { company : true; companyName: string; }
);
この型を使う場合も company
フィールドの値によって他のフィールド何を持つか、 TypeScript ではうまく判定するようになっている。
関数に関しても、複数の異なる引数の形式に対応させた関数を用意できる。 (オーバーロード)
// `sin` と `cos` をまとめた関数 (どうしてこんな関数が必要なのでしょうか?)
type Trigonometric = (
& ( (kind: "sin" , x: number) => number )
& ( (kind: "cos" , x: number) => number )
& ( (kind: "sin+cos", x: number) => [sin: number, cos: number] )
);
これなら、引数の種類に合わせて戻り値の型を分ける、といったこともできてしまう。
注意すべき点は、オブジェクトの場合と異なり、複数の型を繋げるのは |
(or) ではなくて &
(and) である。また、1つ1つの関数型定義を括弧で括らなくてはならない。
関数を実装する方法
このような型を持った関数を定義する際にも多少工夫が必要である。
上記の Trigonometric
の例だと、関数は次のように実装することになる。
// `Trigonometric` を1つにまとめた型
type GenericTrigonometric =
( kind: "sin" | "cos" | "sin+cos", x: number )
=> number | [number, number];
// 関数の実装
const trigonometric = ((
(kind, x) => {
switch (kind) {
case "sin":
return Math.sin(x);
case "cos":
return Math.cos(x);
case "sin+cos":
return [Math.sin(x), Math.cos(x)];
}
}
) as GenericTrigonometric) as Trigonometric;
関数実装自体にはオーバーロードした型として実装することはできない (?) ようなので、まずひとまとめにした GenericTrigonometric
型として関数を定義した上で、オーバーロードした Trigonometric
型にダウンキャストすることになる。