【TypeScript】配列が「空でない」ことを型に埋め込む手法
最近、古くからあるスクレイピングプロジェクトのリファクタリングをしていて、TypeScriptのコンパイラオプションであるnoUncheckedIndexedAccess
をfalse
からtrue
に変更しました。
これが思った以上に大変で、特に要素数が不確定な配列の扱いに苦労しました。
そこで、配列が空でないことを型レベルで保証できればもっと楽になるんじゃないかと思い、いろいろと調べてみました。その過程で見つけた方法や実験してみたことを共有したいと思います。
noUncheckedIndexedAccess を true にするとどうなる?
おさらいとして、noUncheckedIndexedAccess
を true
にすると、配列やオブジェクトのプロパティにアクセスする際にそのプロパティが存在しない可能性を考慮しなければなりません。
const numbers = [1] satisfies number[];
// これだとエラーになる
if (numbers.length > 0) {
// @ts-expect-error
console.log(numbers[0] satisfies number);
}
// こう書けばOK
if (numbers[0] !== undefined) {
console.log(numbers[0] satisfies number);
}
numbers.length > 0
とチェックしても、numbers[0]
が undefined
になる可能性があると言われてしまいます。現在のTypeScriptは配列の長さで条件分岐しても型の絞り込みはできないためです。[1]
とはいえあらゆるindex accessする箇所でundefinedかどうかを確認するのは大変なので、なんらかの手法で配列に事前に型をつけて、安全に取り扱えるように準備すると良いでしょう。
配列が空でないことを型に埋め込む方法
zodを使う
zodでは、.noempty()
メソッドを使って非空の配列を定義できます。
import { z } from 'zod';
const nonemptyArray = z.array(z.string()).nonempty();
type NonemptyArray = z.infer<typeof nonemptyArray>; // [string, ...string[]]
nonempty()
を使うと、内部的には先頭の要素が必ずあるタプル型として定義されます。そのため、[0]
にアクセスしても undefined
を気にしなくて済みます。
最近Xで話題になっていたのもこの方式でした。
ただし、.pop()
などの破壊的メソッドを使うと型と実態がずれてしまうので、readonly も併用しておくのが安全です。
fp-tsのNonEmptyArrayを使う
fp-tsには、NonEmptyArray
という型が用意されています。
import * as NonEmptyArray from 'fp-ts/NonEmptyArray';
const numbers = NonEmptyArray.of(1) satisfies NonEmptyArray<number>;
console.log(numbers[0] satisfies number); // undefined にはならない
NonEmptyArray は以下のように定義されています。
type NonEmptyArray<A> = Array<A> & {
0: A;
};
これにより、[0]
が常に存在することが型で保証されます。こちらにもReadonlyNonEmptyArray<A>
があります。
最後の要素の存在も型で保証したい
先頭の要素が存在することは型で保証できましたが、実は上記の二つの方法どちらも最後の要素はundefined
になる可能性があると推論されてしまいます。
とはいえ、最初の要素が存在するならばかならず最後の要素もあるはず[2]なので、型で推論できたら便利ですよね。ということで実験的に考えてみます。
まず伝統的な[array.length - 1 ]
でインデックスアクセスする方法で考えると、インデックスが動的に変化することになり、事前に要素数がわかっている状態でないと使うのは難しそうでした。
TypeScriptは仕組み上型を定義してからその型に当てはまる値を扱う順番になるためです。
zodでは値から型を定義することもできますがtupleを定義するときはどうしても事前に要素数を定義する必要がありそうでした。
(例えばこんな書き方を試してみましたが、どうしても型アサーションで要素数を定義しないとzodの関数に渡す時にtypecheckが通りませんでした。他にもっといい書き方があるかも。)
import { z } from 'zod';
// 動的に要素数が決まる配列
const array = [1, 2, 3];
const nums = (num: number) => new Array(num).fill(z.number()) as [z.ZodNumber, ...z.ZodNumber[]];
const parsed = z.tuple(nums(array.length));
そこで、ES2022以降の環境で使える.at()をオーバーロードすることで、at(-1)(最後の要素)
が必ず存在することを型で表現できないか試してみました。
interface NonEmptyArray<A> extends Array<A> {
0: A;
at(index: -1 | 0): A;
at(index: number): A | undefined;
}
// 実際にはユーザー定義型ガード関数などをつかって生成することを想定
const strings = ['first', 'middle', 'last'] as NonEmptyArray<string> ;
// 全て型チェックが通る
console.log(strings[0] satisfies string);
console.log(strings.at(-1) satisfies string);
console.log(strings.at(0) satisfies string);
console.log(strings.at(12345) satisfies string | undefined);
このように定義すると、strings.at(-1)
と strings.at(0)
は必ず値を返すことが型で保証できました。[3]
last
関数
ここまで調べてから記事の社内レビューで教えてもらったんですが、fp-tsのNonEmptyArray
にはlast
という関数も用意されていたので、fp-tsを使う場合はこれでスムーズに取り出せました。
import * as NonEmptyArray from 'fp-ts/NonEmptyArray';
const strings = ['first', 'middle', 'last'] as NonEmptyArray<string> ;
const last = NonEmptyArray.last(strings) satisfies string;
終わりに
zodやfp-tsのようなアプローチを使えば配列をよりスムーズに扱えるようになることは分かりましたが、要素数が予測できない配列や、配列の最後の要素など細かいところを綺麗に扱うにはまだまだTypeScript本体やライブラリとユーザーの進化が必要になってきそうです。
PR
株式会社HERPはTypeScriptを使ってプロダクトを成長させる仲間を募集しています。
Discussion