TypeScript でオブジェクトのプロパティの型推論しても、親オブジェクト自体には型推論は適用されない

この記事は個人ブログと同じ内容です

TypeScript でオブジェクトのプロパティの型推論しても、親オブジェクト自体には型推論は適用されない


前置き

こんにちは、 back check 開発エンジニアの @sota_yamaguchi です。

今回は、直近の開発のなかで、 TypeScript の型推論の挙動に対してなぜか思ったように型が適用されないんだが、、、となったことがあったので、調査した結果知見を得られたので記事にしてみました。

結論

先に結論を述べておきます。 TypeScript の仕様として、オブジェクトのプロパティに対して型ガードを行って型を推論しても、親となるオブジェクト自体には型推論の結果が適用されません。

やろうとしたこと

A 型の demoData オブジェクトのプロパティからそれぞれ undefined を省くように絞り込んで、関数 testFunc の props として渡そうとしました。 if を通過したことによって、型推論で B 型の条件を満たせているように見えますが、 if 通過後も demoData の型が A 型として扱われていることによって、 testFunc の引数として渡そうとするとエラーがでてしまいました。

type A = {
  name: string | undefined
  email: string | undefined
}

type B = {
  name: string
  email: string
}

const testFunc = (props: B): void => {
  console.log(props)
}

const demoData: A = {
  name: 'hoge',
  email: 'hoge'
}

// A 型の demoData のプロパティの型 name, email から、それぞれ undefined を省くように絞り込む
if (!demoData.name || !demoData.email) {
  throw 'error'
}

// error: Argument of type 'A' is not assignable to parameter of type 'B'.
testFunc(demoData)

TypeScript の仕様

まず前提として TypeScript は構造的部分型の言語です。つまり、 A 型と B 型のシグネチャが等しければ、 A 型の代わりに B 型を渡しても怒られない言語です。 今回のケースでは、型ガードによって A 型のプロパティの型から undefined を除外したことで、型推論によって A 型は B 型と同等のインターフェースを提供すると推論してくれることを想定していたのですが、試したところエラーになってしまったので理由がわからずに詰まったというケースでした。

これについて調べた結果、 TypeScript の issue で以下のコメントを見つけました。

Type guards do not propagate type narrowings to parent objects. The narrowing is only applied upon access of the narrowed property which is why the destructing function works, but the reference function does not. Narrowing the parent would involve synthesizing new types which would be expensive. タイプガードは、タイプナローイングを親オブジェクトに伝播しません。ナローイングは、ナローされたプロパティにアクセスしたときにのみ適用されます。そのため、破棄関数は機能しますが、参照関数は機能しません。親を絞り込むには、コストのかかる新しいタイプを合成する必要があります。  by google 翻訳

TypeScript の仕様として、型ガードを行っても、型推論の結果は推論を行なったオブジェクトのプロパティにのみ適用され、親のオブジェクトには適用しないことがわかりました。

TypeScript がこれをサポートしていない理由としては、それっぽい回答として以下を見つけたので貼っておきます。(2021/12/30 時点では、この仕様について明言しているドキュメントはないようです)

  • パフォーマンスについて

    In other words, in order to know the type of x we'd have to look at all type guards for properties of x.That has the potential to generate a lot of work. X 型の型情報を知るためには、 X 型が持つすべてのプロパティに対して型ガードを実施して調べる必要があります。それは大量の処理を生み出してしまう可能性があります。 by オレオレ翻訳

回避策

では、 A 型のオブジェクトを B 型として扱うにはどうしたらいいのか。ということで今回の例に対しては以下の方法で回避することが可能です。

  1. 型ガードによって推論が効いているプロパティのみを引数として扱う方法
if (!demoData.name || !demoData.email) {
  throw 'error'
}

testFunc({name: demoData.name, email: demoData.email})

  1. 型ガードによって親オブジェクトにユーザー定義で型をアサインする方法
const isB = (x: A | B): x is B => !!x.name && !!x.email;

if (isB(demoData)) {
  testFunc(demoData);
}

1 の例はプロパティを推論して、そのまま利用しているのに対して、 2 の例はプロパティの検証をしたらおやオブジェクト自体に型のアサインをしています。 推論後の利用したいプロパティが限られているのであれば安全性を重視して 1 を。拡張性や可読性を上げたいなら 2 を。など、場面によってこれらを使い分けていけるとよさそうです。

おわりに

日頃から TypeScript は触っているので自分は慣れている方だと思い込んでいたのですが、今回の調査で何も分かっていなかったことを実感させられました。 いい振り返りにもなるので、また新しい発見があったら随時発信していきたいと思います。