【TypeScript】discriminated unionでnarrowingできていなかった事例
narrowingできない?
私が遭遇した「え?これなんでナローイングできてないの?」な事例を紹介します。
narrowing
narrowing
とは条件文などを利用してTSの型を絞りこむことを指します。
type NumType = number | null
const increment = (num: NumType) => {
if (typeof num === 'number') {
return num + 1 // このときnumの型はnumberであることが確定している
}
return
}
discriminated union
こちらはオブジェクトの中に判別可能なリテラルを持つプロパティが存在しており、これが含まれるUnion型はそのプロパティでオブジェクトの型を判別できるというものです。
type UserA = {
name: '太郎',
gender: string
}
type UserB = {
name: '次郎',
age: number
}
type User = UserA | UserB
const func = (user: User) => {
if (user.name === '太郎') {
return user.gender // nameが太郎なのでUserAに絞り込まれる
}
return user.age // UserBと判定される(ageにアクセスできる)
}
本題
ではできていなかったパターンをみていきましょう。
実はリテラルではない
下記のような場合です。
const userA = {
name: '太郎',
gender: 'male',
}
const userB = {
name: '次郎',
age: 20,
}
type User = typeof userA | typeof userB
const func = (user: User) => {
if (user.name === '太郎') {
return user.gender // Property 'gender' does not exist on type 'User'
}
return user.age // Property 'age' does not exist on type 'User'
}
一見前述したコードと同じに見えますが、この場合AとBの型を絞り込むことはできません。
それはname
プロパティの型がリテラルではなくstring
だからです。
// type UserA
type UserA = {
name: '太郎';
gender: string;
}
// typeof userA
const userA: {
name: string;
gender: string;
}
typeof objct
すると推論されるプロパティの型はプリミティブになります。
そのためdiscriminated union
ではなくなり、オブジェクトの型を絞り込むことができなかったのです。
「そりゃそうだ」となりそうですが、私はこれを見落としていました。
2022.10.22 追記 / const assertion
kazuwombatさんより、as const
を用いた方法をコメントいただきました。
この方法ならtypeof
を利用してもリテラル型が固定されるため、narrowingが効くようになります。
勉強になりました。kazuwombatさんありがとうございます!
const userA = {
name: '太郎',
gender: 'male',
} as const
const userB = {
name: '次郎',
age: 20,
} as const
type User = typeof userA | typeof userB
const func = (user: User) => {
if (user.name === '太郎') {
return user.gender
}
return user.age
}
オプショナルなリテラル
オプショナルとイコール
再度リテラルを持つUser型を定義します。
type User =
| { name: '太郎'; gender: string; }
| { name: '次郎'; age: number; }
この時===
を利用することでオブジェクトを絞り込むことはできました。
では次のような場合はどうでしょうか。
// nameがオプショナルになった
type User =
| { name?: '太郎'; gender: string; }
| { name?: '次郎'; age: number; }
同じように関数を書いてみるとage
にアクセスできません。
それもそのはず、太郎で絞り込んでもname
は次郎
とundefined
の可能性を持っています。
const func = (user: User) => {
if (user.name === '太郎') {
return user.gender //アクセスできる
}
return user.age // アクセスできない
}
オプショナルな場合はそれぞれ比較することで解決します。
const func = (user: User) => {
if (user.name === '太郎') {
return user.gender
}
if (user.name === '次郎') {
return user.age
}
return
}
オプショナルとノットイコール
問題はノットイコールの場合です。
先ほどの関数で行っていた比較をイコールではなくノットイコールで行ってみましょう。
もちろんオプショナルなので存在の判定もつけてあげます。
const func = (user: User) => {
if (!user.name) return
if (user.name !== '太郎') {
user.name // (property) name?: "次郎"
return user.age // Property 'age' does not exist on type 'User'.
}
return user.gender
}
この場合、age
プロパティへのアクセスは型エラーが起こります。
しかしuser.name
にアクセスしようとすると、次郎
と推論されるのです。
つまりname
プロパティの絞り込みはできていても、オブジェクトはできていないことになります。
これはなぜなのでしょうか。
name
があることも、次郎
であることも保証したはずです・・。
TypeScriptの制限
私が所属するiCAREのフェロー、@ozu_syoさんに伺ったところ「TypeScriptが処理に制限をかけているのではないか」とのこと。
それをきっかけにTypeScriptのissueを漁ると次のようなissueとコメントを見つけました。
以下はコメントの抜粋です(翻訳: DeepL)。
I think this is a design limitation in discriminant narrowing which is effectively a top-down process, rather than bottom up.
これは、ボトムアップではなく、事実上トップダウンのプロセスである判別絞り込みの設計上の制限だと思います。
the checker will not compose multiple property narrowings when discriminant pruning. So by top-down I mean that it will not collect state from composite narrowings of the same property and use them.
チェッカは判別プルーニング(discriminantの選定)時に複数のプロパティの絞り込みを合成しません。つまり、トップダウンというのは、同じ性質の複合的な絞り込みから状態を収集して使用しないということです。
トップダウンというのは絞り込みの方法だと思います。
ここでいう!==
を利用した消去法はトップダウンと言えるでしょう。
そして「複数のプロパティの絞り込みをしない」、つまりuser.name
の型のみ絞り込んでいるということになります。
よってオブジェクトがAかBかを判別できないことも納得できます。
2つの条件式を合成すればもちろん判別はつくものの、
トップダウンにの計算はかなり負荷がかかりパフォーマンスが大幅に下がってしまうため、TypeScript側で意図的に制限しているようです。
inを使う
実際に開発する場合、オブジェクトを絞り込みたいときはあるプロパティにアクセスしたいケースがほとんどではないでしょうか。
そんなときはin
を利用して、リテラルではなくプロパティでnarrowingしてあげると良いでしょう。
const func = (user: User) => {
if ('gender' in user) {
return user.gender
}
return user.age
}
あとがき
「あれー、絞り込めてそうなんだけどなー」という場面は意外と多いのではないでしょうか。
そんな方の救いに少しでもなれれば幸いです。
また「オプショナルとノットイコール」の件では@ozu_syoさんに多くのアドバイスをいただきました。
この場を借りて感謝申し上げます。
参考
Discussion
面白い記事でした!
ありがとうございます!
記事の主題上、知っていてあえて書いてないような気もしてるんですが、実はリテラルではないのところは
as const
使えば、リテラルで型定義され、narrowingは効くようになります🙆コメントありがとうございます!
大変勉強になりました🙏
2022.10.22 追記 / const assertionで内容を追加させていただきました!