🈳

【TypeScript】配列が「空でない」ことを型に埋め込む手法

2024/11/20に公開

最近、古くからあるスクレイピングプロジェクトのリファクタリングをしていて、TypeScriptのコンパイラオプションであるnoUncheckedIndexedAccessfalseからtrueに変更しました。
これが思った以上に大変で、特に要素数が不確定な配列の扱いに苦労しました。

そこで、配列が空でないことを型レベルで保証できればもっと楽になるんじゃないかと思い、いろいろと調べてみました。その過程で見つけた方法や実験してみたことを共有したいと思います。

noUncheckedIndexedAccess を true にするとどうなる?

おさらいとして、noUncheckedIndexedAccesstrue にすると、配列やオブジェクトのプロパティにアクセスする際にそのプロパティが存在しない可能性を考慮しなければなりません。

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で話題になっていたのもこの方式でした。
https://x.com/mizchi/status/1856238211183628644

ただし、.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を使ってプロダクトを成長させる仲間を募集しています。
https://herp.careers/v1/herpinc/zVEuWwkjSuW5

脚注
  1. 実は[undefined]のようなundefinedだけの配列や、new Array(n)で生成した長さだけが決まっていて中身が空(empty)な配列も存在するため、長さだけではなく中身の型も考慮してnarrowingするように進化しないと難しそうです。 ↩︎

  2. 要素が少なくとも1つあればそれが最初の要素であり最後の要素でもある。 ↩︎

  3. 実際に使うと引数の型が狭くなるため、他の関数との互換性が失われる可能性があります。その場合はas Array<A>のようにアップキャストして渡す必要がありそうです。 ↩︎

Discussion