TypeScriptの呼吸、八の型【型安全なfilter】
課題感
const nullableArray = ['', '', null];
const nonNullableArray = nullableArray.filter((item) => item !== null);
このとき、nonNullableArray
の型がstring[]
になってほしいけど、(string | null)[]
になってしまう。
typescriptも流石にここまでは気を利かせて推論してくれない。
安易な解決法1
as
を使えばいんじゃね?
const nullableArray = ['', '', null];
const nonNullableArray = nullableArray.filter((item) => item !== null) as string[];
これで万事解決だろう、と。
なるほど、一見、良さそうにも思える。
だが実際は、問題がある。
安易なas
の使用は、できるだけ避けたい。
as
の危険性については、uhyoさんの記事『敗北者のtypescript』が詳しい。
この場合だと、as
は以下のような危険性を秘めている
const nullableArray = ['', '', 1, null]; // (string | number | null)[]
const nonNullableArray = nullableArray.filter((item) => item !== null) as string[];
ここでは、 nonNullableArray
は本来、(string | number)[]
型であるべきだが、
不用意にas
を使っているせいで、string[]
型と推論されてしまう。
安易な解決法2
is
を使えばいんじゃね?
const nullableArray = ['', '', null];
const nonNullableArray = nullableArray.filter(
(item): item is string => item !== null
);
これで万事解決だろう、と。
なるほど、一見、良さそうにも思える。
だが実際は、問題がある。
安易なis
の使用も、できるだけ避けたい。
is
の危険性についても、やはり既出の『敗北者のtypescript』に詳しく載っている。
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
とそんなに違いがないと思われる。
ベターな解決法1
is
とExclude
を組み合わせて使う。
この解決法は、以下の記事を参照した。
(きむそんさん、ありがとうございます。)
きむそんさんの記事を見ながら、型推論からnull
を排除したいという今回のケースに当てはめると、次のようなコードになる。
const nullableArray = ['', '', null];
const nonNullableArray = nullableArray.filter(
(item): item is Exclude<typeof item, null> => item !== null,
);
このパターンだと、先ほどのas
やis
と同じ問題は起こり得るだろうか?
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
に真の平和と型安全性がもたらされた........
........かのように思われた。
だが、しかし、is
とExclude
のタッグは十分良いにしても完璧ではない。
具体的には、以下のような問題がある。
const nullableArray = ['', '', 1, null];
const nonNullableArray = nullableArray.filter(
+ (item): item is Exclude<typeof item, null> => item !== undefined,
);
この例では item !== null
と書くべきところ、うっかり item !== undefined
と書き間違えてしまったようだ。
そうなるとnull
はnullableArray
から排除されない。
だから、本来であれば、nonNullableArray
の型は(string | number |null)[]
となるべきだ。
だが実際は、is
は我々の書いた型ガートが間違っていることを理解できるほど賢くはないため、誤って(string | number)[]
と推論されてしまう。
まとめると、型ガートを間違えると型も間違ってしまうということである。
もちろん、先ほどのきむそんさんの記事でもこの点については、
ユーザー定義型ガードを使う以上こういった問題は避けられません
と、既にちゃんと言及されている。
ベターな解決法2
ということで、filter
のさらなる型安全性を求めて旅に出ていたところ、次の記事に出会った。
この記事には次のように書かれている。
これを避けるにはTypeGuardの関数を入念にテストするなどしかないだろう。
実際filterを使うケースはnullを除外するなどがせいぜいと考えると、割に合わなくて面倒に感じるケースも多い。
これはわかりみが深い。
正直、たかがfilter
でnull
を除外するためだけにテストを書くのもなー、と私も思う。
そこで、ライブラリに頼ってしまおうということである。
自分で書いてミスるよりはむしろ、大人しく先人の資産を借りた方が安牌だとも考えられる。
実際に使ってみる。
$ npm install --save-dev typesafe-utils
import { isNotNull } from 'typesafe-utils';
const nullableArray = ['', '', 1, null];
const nonNullableArray = nullableArray.filter(isNotNull);
よし、ちゃんと、nonNullableArray
が(string | number)[]
型に推論されている。
コードの見た目もis
とExclude
のタッグよりスッキリするし、感触としては最高だ。
今回使ったisNotNull
以外にもTypeGuard
が取り揃っていて、非常に良いライブラリである。
でも、GitHubでのスター数がたった70なのが、心許ない感じはする。
ベターな解決法3
そこで、結局はまぁ、多少面倒ではあるが、
自分で型ガードを書いて、テストもちゃんとしようという話になるかもしれない。
必要最小限の型ガードと、めっちゃざっくりしたテストだと以下のような感じになりそうだ。
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;
};
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]);
});
});
結論
次のうちどれかを選ぶのがおすすめ。
-
is
とExclude
を用いてTypeGuard
を書く - 型ガードのユーティリティライブラリを導入する
- 自前で型ガードを書いて、=テストもちゃんと行う
それぞれの方法にメリット・デメリットがあるので、チームで話し合って決めるのが良さそう。
以下は個人的見解。
個人開発でとりあえずさっさと作りたいときは①is
とExclude
を使う。
チーム開発のときは、②typesafe-utils
を提案してみて、「スター数的にあまり導入したくないかな..」という意見が出たら、③自前で 型ガードを書いてテストもちゃんと行う方針を取る。
追記
このスクラップを書いたあとに、flatMapを使おうという記事も出た。
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
);
こんな感じでどうか...?