🐕

配列の filter で直和型を絞り込むときのユーザー定義型ガードを比較的型安全に書く

2022/09/24に公開

配列の filter メソッドでは直和型が絞り込まれない

配列 の filter メソッドでは直和型の絞り込みができないことが知られています

type User = {
  id: number
  name: string
}

declare const maybeUsers: (User | undefined)[]

const users /*: (User | undefined)[] */ = maybeUsers.filter(
  (maybeUser) => maybeUser !== undefined
)

!== undefined しているので自明に undefined は除かれて User[] 型に推論されてほしいところですが、callback は boolean を返すだけなので (User | undefined)[] に推論されていしまいます

ユーザー定義型ガードによる解決と型安全性

この問題への一般的な解決策としてユーザー定義型ガードを使ったやり方がよく使われます

const usersGuarded /*: User[] */ = maybeUsers.filter(
  (maybeUser): maybeUser is User => maybeUser !== undefined
)

これで User[] 型に絞り込むことができました

ただし、ユーザー定義型ガードはガードの実装が正しいかを型チェックで検証できないので、型安全性の意味で危険なものです

例えばうっかり === undefined にしてしまうと

const usersGuarded /*: User[] */ = maybeUsers.filter(
  (maybeUser): maybeUser is User => maybeUser === undefined
)

真逆なことを書いてしまっていて usersGuarded には実際には undefined[] な配列が入りますが有効です

あるいは、将来的に maybeUsers の型が変わった時にも

declare const maybeUsers: (
  | User
  | OtherUser // 後から追加された
  | undefined
)[]

const usersGuarded /*: User[] */ = maybeUsers.filter(
  (maybeUser): maybeUser is User =>
    maybeUser !== undefined /* OtherUser かをチェックしていない */
)

これは誤ったユーザー定義型ガードの実装になります(OtherUser である可能性が不当に除かれてしまいます)が、型チェックでこれを検知することはできません

Exclude<typeoof arg, undefined> で書く

ユーザー定義型ガードを使う以上こういった問題は避けられませんが、ちょっとマシな書き方があります

Exclude<typeoof arg, undefined> です

const usersGuarded /*: User[] */ = maybeUsers.filter(
  (maybeUser): maybeUser is Exclude<typeof maybeUser, undefined> =>
    maybeUser !== undefined
)

この書き方だと引数の型から undefined を除いた型になるので、maybeUser の型が変更されても伴って型ガードされる型も変わるので安全ですし、実態に即しています

やや冗長ですが、これで将来的な変更で型ガードの実装が壊れることは防げるようになりました

応用編

ちょっと応用編です

filter で使いたくなるユーザー定義型ガードは本来 if 文の中ではフロー解析でユーザー定義型ガードを使わずとも型を絞り込むことができるものも多いです

これまでの例の undefined を取り除く例も if の分岐なら危険なユーザー定義型ガードを使わずに絞り込むことができます

declare const maybeUser: User | undefined

if (maybeUser === undefined) {
  // この分岐の中では `User` に絞り込まれる
} else {
  // こちらの分岐では `undefined` に絞り込まれる
}

そこで、フロー解析であれば型を絞り込めることを利用して

;(maybeUser, exclude) => (maybeUser !== undefined ? maybeUser : exclude)

なインタフェースで、利用時に filter 関数を渡す形にできれば安全に利用できます

このためのユーティリティを準備します

const guardSymbol = Symbol()
type GuardSymbol = typeof guardSymbol

export const filterGuard =
  <Base, Ret extends Base | GuardSymbol>(
    cb: (base: Base, exclude: GuardSymbol) => Ret
  ): ((base: Base) => base is Exclude<Ret, GuardSymbol>) =>
  (base: Base): base is Exclude<Ret, GuardSymbol> =>
    cb(base, guardSymbol) !== guardSymbol

利用側は

const usersGuarded /*: User[] */ = maybeUsers.filter(
  filterGuard((maybeUser, exclude) =>
    maybeUser !== undefined ? maybeUser : exclude
  )
)

こんな感じで、型ガードされた値と、除く側を表す第二引数の exclude を返してもらいます

これでフロー解析でガードできるものは filter でも安全にガードできるようになりました

(filterGuard の実装が間違ってたら壊れるのは変わらずですが、実装ミスの危険性がここに閉じてるので必要になるたびにユーザー定義型ガードを書くより安全だと思います)

インタフェースがわかりにくいのはそうなので、型安全性と天秤にかけて使いたければという感じになります

番外編: flatMap を使う

filter とは記事の趣旨がズレますが、flatMap を使えば型を除きたい側の分岐で空配列を返し、型が絞り込まれた側で引数を返してあげることで、ユーザー定義型ガードなしに配列を絞り込むことができます

const usersGuarded /*: User[] */ = maybeUsers.flatMap((maybeUser) =>
  maybeUser === undefined ? [] : [maybeUser]
)

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

まとめ

できるだけ安全に filter しようぜ

Discussion