Open8

TypeScriptの呼吸、八の型【型安全なfilter】

t_keshit_keshi

課題感

const nullableArray = ['', '', null];

const nonNullableArray = nullableArray.filter((item) => item !== null);

このとき、nonNullableArrayの型がstring[]になってほしいけど、(string | null)[]になってしまう。
typescriptも流石にここまでは気を利かせて推論してくれない。

t_keshit_keshi

安易な解決法1

asを使えばいんじゃね?

const nullableArray = ['', '', null];

const nonNullableArray = nullableArray.filter((item) => item !== null) as string[];

これで万事解決だろう、と。
なるほど、一見、良さそうにも思える。

だが実際は、問題がある。

安易なasの使用は、できるだけ避けたい。
asの危険性については、uhyoさんの記事『敗北者のtypescript』が詳しい。
https://qiita.com/uhyo/items/aae57ba0734e36ee846a#asの危険性

この場合だと、asは以下のような危険性を秘めている

const nullableArray = ['', '', 1, null]; // (string | number | null)[]

const nonNullableArray = nullableArray.filter((item) => item !== null) as string[];

ここでは、 nonNullableArrayは本来、(string | number)[]型であるべきだが、
不用意にasを使っているせいで、string[]型と推論されてしまう。

t_keshit_keshi

安易な解決法2

isを使えばいんじゃね?

const nullableArray = ['', '', null];

const nonNullableArray = nullableArray.filter(
   (item): item is string => item !== null
);

これで万事解決だろう、と。
なるほど、一見、良さそうにも思える。

だが実際は、問題がある。

安易なisの使用も、できるだけ避けたい。
isの危険性についても、やはり既出の『敗北者のtypescript』に詳しく載っている。

https://qiita.com/uhyo/items/aae57ba0734e36ee846a#isの活用

uhyoさんの記事によれば、isを使えば「危険性の影響範囲を小さくする」を小さくすることはできるけれども、「その関数が誤った判断を行えば間違った型がついてしまう」という危険も伴う。

この場合だと、asは以下のような危険性を秘めている。

const nullableArray = ['', '', 1, null]; // (string | number | null)[]

const nonNullableArray = nullableArray.filter((item) => item !== null) as string[];

ここでは、nonNullableArrayは本来(string | number)[]型であるべきだが、
不用意にisを使っているせいで、string[]型と推論されてしまう。

isを使うのは玄人っぽいやり方だが、この「filterの型推論問題」においては抱える危険性はasとそんなに違いがないと思われる。

t_keshit_keshi

ベターな解決法1

isExcludeを組み合わせて使う。

この解決法は、以下の記事を参照した。

https://zenn.dev/kimuson/articles/filter_safety_type_guard

(きむそんさん、ありがとうございます。)

きむそんさんの記事を見ながら、型推論からnullを排除したいという今回のケースに当てはめると、次のようなコードになる。

const nullableArray = ['', '', null];

const nonNullableArray = nullableArray.filter(
  (item): item is Exclude<typeof item, null> => item !== null,
);

このパターンだと、先ほどのasisと同じ問題は起こり得るだろうか?

const nullableArray = ['', '', 1, null]; // (string | number | null)[]

const nonNullableArray = nullableArray.filter(
  (item): item is Exclude<typeof item, null> => item !== null,
);

答えは、NOである。
この場合、nonNullableArrayは正しく(string | number)[]型と推論される。
先ほどのように、誤ってstring[]型だと推論されることはない。

こうして我々のfilterに真の平和と型安全性がもたらされた........

........かのように思われた。

だが、しかし、isExcludeのタッグは十分良いにしても完璧ではない。
具体的には、以下のような問題がある。

const nullableArray = ['', '', 1, null];

const nonNullableArray = nullableArray.filter(
+ (item): item is Exclude<typeof item, null> => item !== undefined,
);

この例では item !== nullと書くべきところ、うっかり item !== undefinedと書き間違えてしまったようだ。
そうなるとnullnullableArrayから排除されない。
だから、本来であれば、nonNullableArrayの型は(string | number |null)[]となるべきだ。
だが実際は、isは我々の書いた型ガートが間違っていることを理解できるほど賢くはないため、誤って(string | number)[]と推論されてしまう。

まとめると、型ガートを間違えると型も間違ってしまうということである。

もちろん、先ほどのきむそんさんの記事でもこの点については、

ユーザー定義型ガードを使う以上こういった問題は避けられません

と、既にちゃんと言及されている。

t_keshit_keshi

ベターな解決法2

ということで、filterのさらなる型安全性を求めて旅に出ていたところ、次の記事に出会った。

https://zenn.dev/terrierscript/articles/2021-06-09-array-filter-typesafe-utils

この記事には次のように書かれている。

これを避けるにはTypeGuardの関数を入念にテストするなどしかないだろう。
実際filterを使うケースはnullを除外するなどがせいぜいと考えると、割に合わなくて面倒に感じるケースも多い。

これはわかりみが深い。
正直、たかがfilternullを除外するためだけにテストを書くのもなー、と私も思う。

そこで、ライブラリに頼ってしまおうということである。
自分で書いてミスるよりはむしろ、大人しく先人の資産を借りた方が安牌だとも考えられる。

実際に使ってみる。

$ npm install --save-dev typesafe-utils
import { isNotNull } from 'typesafe-utils';

const nullableArray = ['', '', 1, null];

const nonNullableArray = nullableArray.filter(isNotNull);

よし、ちゃんと、nonNullableArray(string | number)[]型に推論されている。
コードの見た目もisExcludeのタッグよりスッキリするし、感触としては最高だ。
今回使ったisNotNull以外にもTypeGuardが取り揃っていて、非常に良いライブラリである。

でも、GitHubでのスター数がたった70なのが、心許ない感じはする。

t_keshit_keshi

ベターな解決法3

そこで、結局はまぁ、多少面倒ではあるが、
自分で型ガードを書いて、テストもちゃんとしようという話になるかもしれない。

必要最小限の型ガードと、めっちゃざっくりしたテストだと以下のような感じになりそうだ。

util/typeGuard.ts
export const isNonNullish = <TValue>(value: TValue | undefined | null): value is TValue => {
  return value !== undefined && value !== null;
};

export const isDefined = <TValue>(value: TValue | undefined): value is TValue => {
  return value !== undefined;
};

export const isNonNullable = <TValue>(value: TValue | null): value is TValue => {
  return value !== null;
};
util/typeGuard.test.ts
import { isNonNullish, isDefined, isNonNullable } from './typeGuard';

const testData = ['', '', 1, null, undefined];

describe('typeGuardのテスト', () => {
  test('isNonNullish', () => {
    expect(testData.filter(isNonNullish)).toStrictEqual(['', '', 1]);
  });
  test('isDefined', () => {
    expect(testData.filter(isDefined)).toStrictEqual(['', '', 1, null]);
  });
  test('isNonNullabel', () => {
    expect(testData.filter(isNonNullable)).toStrictEqual(['', '', 1, undefined]);
  });
});
t_keshit_keshi

結論

次のうちどれかを選ぶのがおすすめ。

  1. isExcludeを用いてTypeGuardを書く
  2. 型ガードのユーティリティライブラリを導入する
  3. 自前で型ガードを書いて、=テストもちゃんと行う

それぞれの方法にメリット・デメリットがあるので、チームで話し合って決めるのが良さそう。

以下は個人的見解。

個人開発でとりあえずさっさと作りたいときは①isExcludeを使う。

チーム開発のときは、②typesafe-utilsを提案してみて、「スター数的にあまり導入したくないかな..」という意見が出たら、③自前で 型ガードを書いてテストもちゃんと行う方針を取る。

t_keshit_keshi

追記

このスクラップを書いたあとに、flatMapを使おうという記事も出た。

https://zenn.dev/spacemarket/articles/51613197db688d

flatMapについて、きむそんさんの記事には以下のように書いてある。

ユーティリティもいらないし冗長でもないし flatMap でよくない?って話ですが、flatMap は filter よりパフォーマンスが悪いので、データ数によっては望ましくないため、用途に応じて使い分けるのが良いと思います

flatMapには「絞り込みを行なっているんだよ」っていうのがメソッド名からは読み取れなくなってしまうというデメリットもあると思う。
なので、やはり全部 flatMap っていうんじゃなくて、「用途に応じて使い分けるのが良い」というのは同意。

追記2

ちなみにfindの場合にも、filterと同じようなテクが使える。

filterのサンプルを再掲。

const nullableArray = ['', '', 1, null]; // (string | number | null)[]

const nonNullableArray = nullableArray.filter(
  (item): item is Exclude<typeof item, null> => item !== null,
);

これの逆版というか、find版を書いてみる。

const nullableArray = ['', '', 1, null]; // (string | number | null)[]

const nullArray = nullableArray.find(
 (item): item is Extract<null, typeof item> => item === unull
);

こんな感じでどうか...?