TypeScriptのユニオン型で「あるかもしれない」プロパティを表現するときのTips
2020年8月18日 公開 / 2020年8月19日 更新
TypeScriptのユニオン型はとても強力な機能で、TypeScriptのコードベースでは広く利用されています。
例えば、次のようにすると「foo
プロパティを持つオブジェクトまたはbar
プロパティを持つオブジェクト」という型を表現できます。
type FooObj = { foo: string };
type BarObj = { bar: number };
type FooOrBar = FooObj | BarObj;
const fooObj: FooOrBar = { foo: "uhyohyo" };
const barObj: FooOrBar = { bar: 1234 };
無いかもしれないプロパティ
上のFooOrBar
型を持つオブジェクトは、foo
プロパティを持つ(FooObj
)かもしれないし持たない(BarObj
)かもしれません。
これはbar
も同じで、つまり「あるかもしれない」プロパティです。
逆の言い方をすれば、foo
やbar
は「無いかもしれない」プロパティであるとも言えます。
このような場合、TypeScriptはFooOrBar
型のオブジェクトのfoo
やbar
にアクセスさせてくれず、コンパイルエラーとしてしまいます。
function useFooOrBar(obj: FooOrBar) {
// エラー: Property 'foo' does not exist on type 'FooOrBar'.
console.log(obj.foo);
}
JavaScript的に考えれば、foo
が無ければプロパティアクセスの結果としてはundefined
となるので、obj.foo
を例えばstring | undefined
型としてアクセスを許してもいいように思えます。
しかし、そういうわけにもいきません。
なぜなら、TypeScriptは構造的部分型を採用しているからです。
より具体的に言えば、BarObj
は「foo
を持たない」のではなく「foo
は型で言及されていないから不明」です。
例えば、次のようにfoo
にnumber
が入っているオブジェクトも、BarObj
に入れたりFooOrBar
に入れることができます。
const o = { foo: 123, bar: 456 };
const bar: BarObj = o;
const foobar: FooOrBar = o;
つまり、FooOrBar
型のobj
に対して、obj.foo
をstring | undefined
型と考えるのは間違いで、正確には「何が入っているか全く不明」なのです。
そのため、TypeScriptはこれをコンパイルエラーとして扱うのです。
エラーの対処法
ところが、実際には「BarObj
のときはfoo
は存在しないのだから、FooOrBar
に対してfoo
でアクセスしたらstring | undefined
にしてほしい」というシチュエーションもあるでしょう。
何より、そうするとobj.foo
のようなアクセスがコンパイルエラーとならなくなり便利です。
そのための方法は意外と簡単です。 次のように、存在しないプロパティに対して明示的に存在しないことを示せばよいのです。
type FooObj = {
foo: string;
bar?: undefined;
};
type BarObj = {
foo?: undefined;
bar: number;
}
type FooOrBar = FooObj | BarObj;
こうすると、例えばBarObj
においては「foo
プロパティは存在しなくてもよく、存在しても必ずundefined
である」という意味になります。
この定義ならば、次のようにfoo
にnumber
が入っているものをBarObj
に入れようとすればコンパイルエラーとなります。
const o = { foo: 123, bar: 456 };
// エラー: Type '{ foo: number; bar: number; }' is not assignable to type 'BarObj'.
// Types of property 'foo' are incompatible.
// Type 'number' is not assignable to type 'undefined'.
const bar: BarObj = o;
また、こうするとFooOrBar
のfoo
プロパティにしてもエラーは発生せず、次のようなコードが可能になります。
書きやすくて便利ですね。
function useFooOrBar(obj: FooOrBar) {
if(obj.foo !== undefined) {
// ...
}
}
このように、存在しないプロパティを明示するためにプロパティ?: undefined
とするのは便利な小技です。
使いこなしましょう。
TypeScriptコンパイラによる対応
ちなみに、このテクニックはTypeScriptコンパイラによる型推論にも組み込まれています。
次のような関数getFooOrBar
を作った場合を考えてみます。
function getFooOrBar() {
if (Math.random() < 0.5) {
return {
foo: "foooooo"
}
} else {
return {
bar: 123
}
}
}
const res = getFooOrBar();
// エラーが起きない!
console.log(res.foo);
こうすると、getFooOrBar
の返り値の型は{ foo: string; bar?: undefined; } | { bar: number; foo?: undefined; }
と推論されます。
型推論の結果に先ほど紹介したテクニックが使われていますね。
これにより、上の例のようにres.foo
にアクセスしてもコンパイルエラーとなりません。
まとめ
コーディングの快適性まで考えて型定義を書けるようになって、一段上のTypeScript使いを目指しましょう!
補足: in
による絞り込みについて
TypeScriptに詳しい読者の方はお気づきかもしれませんが、実は冒頭の「obj.foo
にアクセスできない」問題についてはin
を使って解決する方法もあります。
type FooObj = { foo: string };
type BarObj = { bar: number };
type FooOrBar = FooObj | BarObj;
function useFooOrBar(obj: FooOrBar) {
if ('foo' in obj) {
// ここではobjの方がFooObjに絞り込まれる
console.log(obj.foo);
}
}
この'foo' in obj
という式は、obj
が(ランタイムに)foo
というプロパティを持つかどうかを判定する式です。
こうすると、TypeScriptがin
を見て型を絞り込んでくれるため、if文の中ではobj
がFooObj
型になりobj.foo
というアクセスが可能になります。
しかし、残念ながら型の絞り込みのためにin
を使用するのはおすすめしません。
なぜなら、TypeScriptコンパイラが判断を間違い、型安全性が損なわれる恐れがあるからです。
具体的には次のような場合です。
type FooObj = { foo: string };
type BarObj = { bar: number };
type FooOrBar = FooObj | BarObj;
function useFooOrBar(obj: FooOrBar) {
if ('foo' in obj) {
// ここではobjの方がFooObjに絞り込まれる
console.log(obj.foo);
// obj.fooはstring型なのでstring型のメソッドが使用可能
obj.foo.slice(0, 10);
}
}
// o はFooObjではなくBarObj型
const o = { foo: 123, bar: 456 };
// しかし間違ってFooObjと判定されてランタイムエラーが発生!
useFooOrBar(o);
ここに潜む罠は、先ほど解説した通り、この定義だとBarObj
がfoo
プロパティを持たないとは限らないという点にあります。
'foo' in obj
はobj
がFooObj
かどうかを確実に判定できる式ではないのです。
それにも関わらずTypeScriptコンパイラが型の絞り込みを行なってしまうのが微妙な点です。
以上の理由から、in
を型の絞り込みに使うのはお勧めしません。