🎭

【TypeScript】discriminated unionでnarrowingできていなかった事例

2022/10/18に公開約5,100字2件のコメント

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型はそのプロパティでオブジェクトの型を判別できるというものです。
https://typescript-jp.gitbook.io/deep-dive/type-system/discriminated-unions

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とコメントを見つけました。
https://github.com/microsoft/TypeScript/issues/31404#issuecomment-492569479
以下はコメントの抜粋です(翻訳: 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さんに多くのアドバイスをいただきました。
この場を借りて感謝申し上げます。

参考

https://www.typescriptlang.org/docs/handbook/2/narrowing.html
https://typescript-jp.gitbook.io/deep-dive/type-system/discriminated-unions
https://zenn.dev/estra/articles/typescript-narrowing
https://zenn.dev/estra/articles/typescript-narrowing-patterns
https://github.com/microsoft/TypeScript/issues/31404
https://github.com/microsoft/TypeScript/issues/49933

GitHubで編集を提案

Discussion

面白い記事でした!
ありがとうございます!

記事の主題上、知っていてあえて書いてないような気もしてるんですが、実はリテラルではないのところはas const使えば、リテラルで型定義され、narrowingは効くようになります🙆

const userA = {
  name: '太郎',
  gender: 'male',
} as const
// const userA: {
//    readonly name: "太郎";
//    readonly gender: "male";
//}
ログインするとコメントできます