uhyo/blog

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も同じで、つまり「あるかもしれない」プロパティです。 逆の言い方をすれば、foobarは「無いかもしれない」プロパティであるとも言えます。

このような場合、TypeScriptはFooOrBar型のオブジェクトのfoobarにアクセスさせてくれず、コンパイルエラーとしてしまいます。

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は型で言及されていないから不明」です。 例えば、次のようにfoonumberが入っているオブジェクトも、BarObjに入れたりFooOrBarに入れることができます。

const o = { foo: 123, bar: 456 };
const bar: BarObj = o;
const foobar: FooOrBar = o;

つまり、FooOrBar型のobjに対して、obj.foostring | 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である」という意味になります。 この定義ならば、次のようにfoonumberが入っているものを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;

また、こうするとFooOrBarfooプロパティにしてもエラーは発生せず、次のようなコードが可能になります。 書きやすくて便利ですね。

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文の中ではobjFooObj型になり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);

ここに潜む罠は、先ほど解説した通り、この定義だとBarObjfooプロパティを持たないとは限らないという点にあります。 'foo' in objobjFooObjかどうかを確実に判定できる式ではないのです。 それにも関わらずTypeScriptコンパイラが型の絞り込みを行なってしまうのが微妙な点です。

以上の理由から、inを型の絞り込みに使うのはお勧めしません。