📝

in演算子のnarrowing で少しハマった話

2024/12/02に公開

普段Typesctiptを使ってフロント開発をしているのですが、最近narrowing周りで直感的に理解できない事象に当たりました。その備忘として記事に残そうと思います。

事象

ある文字列"key"がobj2に含まれている場合、何かの処理をする、という分岐を書きます。

const key:string = "key"
const obj:Record<string,string>= {
  key1:"val1",
  key2:"val2"
}

if (key in obj){
  const val = obj[key]
}

雰囲気で書いているとif文で分岐することでnarrowingされ、valについてはstring型に絞られていると思ってしまいます。
実際とある環境でこのようなコードを書いた時にはstring型で推論されていたので、他のリポジトリのコードについても同様の挙動を期待していました。

しかしvalの型を確かめてみるとstring | undefined型になっており、string型に絞れていない!?と混乱してしまいました。

原因と対処

前提 ~noUncheckedIndexedAccess

narrowingできていなかったコードではtsconfigでnoUncheckedIndexedAccessのオプションをtrueにしていました。
noUncheckedIndexedAccessとはtypesctipt4.1で追加されたオプションで、プロパティもしくはインデックスによるアクセスで得た値に対して、undefinedの可能性を考慮し、より厳密な型推論を可能にするものです。
noUncheckedIndexedAccessがoffの場合、先の例で実際に存在しないkeyを指定してobj2のvalueにアクセスするとstring型で推論されてしまいます。
noUncheckedIndexedAccessをonにするとstringとundefinedのunion型で推論されます。

const obj2:Record<string,string>= {
  key1:"val1",
  key2:"val2"
}

const val3=  obj2.key3
const val4=  obj2["key4"]
//noUncheckedIndexedAccessがoff→ string
//noUncheckedIndexedAccessがon → string | undefined 

narrowingでできていたコードではnoUncheckedIndexedAccessをoffにしていたのでnarrowingできていたように見えていただけで、実際はif分岐を書かずともvalの値がstrigとなっていました。
とはいえ型安全なコードを書くにはnoUncheckedIndexedAccessをonの設定を入れておいた方がよいので、offにするという対応はよろしくなさそうです。

原因と対処

in演算子によるnarrowingするにはkeyの値をstring literalする必要がありました。
なので先ほどのコードから、keyをリテラルにしてやると無事narrowingされます。

const key = "key"
const obj:Record<string,string>= {
  key1:"val1",
  key2:"val2"
}
if (key in obj){
  const val = obj[key]
}

//もちろんアサーションでもOK
//if (key in obj) {
//  const val = obj[key as "key"]; // 型を明示
//}

もしくはin演算子によるnarrowingではなく、シンプルにobj[key]の値でnarrowingするというのもあります。ちなみにこの場合はkeyがstringでもリテラルでもどちらでも機能します。

if (obj[key]){
  const val = obj[key]
}

補足

調べていく中で他にもin演算子によるハマりどころがありました。
以下のようにstring|undefined型のkeyを持つオブジェクトについて、そのkeyがある場合XXXするというような処理を書きたいと思います。
in演算子を用いてnarrowingすることでval = obj.key2はstring型に絞られているように見えますが、実際はstring|undefined型のままでした。

type Obj = {
  key1:string
  key2?:string
};

const obj:Obj={
  key1:"key1",
  key2:"key2"
}

if("key2" in obj){
  const val = obj.key2 //string | undefined
}

in演算子によるnarrowingではそのキーでアクセスした値がundefinedか否かまでは確認できないからですね。確かにundefined型を明示的に入れることもできるのでこの挙動はなんとなく腑に落ちます。

const obj:Obj={
  key1:"key1",
  key2:undefined
}

所感

ノリと勢いでnarrowingしているとたまに想定外の型エラーに出くわすことが多いです。
typescriptは現在も型推論に関する更新をどんどん入れているので今後のバージョンではまた挙動が変わるかもしれませんね。
型周りの正しい知識とtypescriptの型推論の仕様については正確におさえたいなあと思った夜でした。

参考

https://zenn.dev/lollipop_onl/articles/eoz-ts-no-unchecked-indexed-access
https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/#no-unchecked-indexed-access

Discussion