hiroaki's blog

技術系を中心に気になったこととかいろいろと。

TypeScript の Union Types について再確認してみた

TypeScript の Union Types でうまく型が認識されず、プロパティのサジェストが効かなかったので改めて仕様を確認してみた。 結論としては「Union Type でそれぞれの Type で異なるプロパティがあった場合、どちらの Type もコード上あり得るからそのままでは使用できない。」という当たり前のところだった。

Generics と絡んでいるコードだったのでそっちが原因かなと思って問題の特定に時間がかかってしまった。雰囲気で利用せずに基本的なところはちゃんと公式ドキュメントを見て理解しておきましょうという反省も込めて内容を記載しておく。

発生した問題

問題となったのは下記のようなコード。

type Props1 = {
  id: string
  title: string
}

type Props2 = {
  id: string
  title: string
  owner: {
    id: string
    name: string
  }
}

type UnionType = Props1 | Props2

export type Props<T extends UnionType> = {
  params: T
}

export const SampleComponent = <T extends UnionType>({ params }: Props<T>) => {
  console.log(params.title) // Success : これは引数の Type が解釈される
  console.log(params.owner.name) // Error : Property 'owner' does not exist on type 'Props1'.
}

SampleComponent({
  params: {
    id: "1",
    title: "sample",
    owner: { id: "2", name: "Imai" },
  },
})

コメントで書いているが、1 つ目の id は Type として認識されてサジェストされるが 2 つ目の ownerProperty 'owner' does not exist on type 'Props1'. のエラーが表示された。とある既存サービスの改修をしようとしていて対象箇所で Generics を使っていたので、Union した Type に Extends するとうまく型が解釈されないのかと予想したのだが、そもそもの Union Type の基本的なところが原因だった。

既に Deprecated なドキュメントだが TypeScript の公式ドキュメントに同様のケースが載っていた。 www.typescriptlang.org

interface Bird {
  fly(): void;
  layEggs(): void;
}
 
interface Fish {
  swim(): void;
  layEggs(): void;
}
 
declare function getSmallPet(): Fish | Bird;
 
let pet = getSmallPet();
pet.layEggs();
 
// Only available in one of the two possible types
pet.swim();
Property 'swim' does not exist on type 'Bird | Fish'.
  Property 'swim' does not exist on type 'Bird'.

Union Type なのでもともとの 2 つの型についてはどちらが入ってきても良い想定である。 となった場合に上記の例だと Bird インターフェースは swim メソッドを持っていないのでコンパイルエラーとなる。 この場合に使用できるのは「どちらのインターフェースも持っている layEggs というメソッド」のみとなる。

解決策

で、どうすれば良いかというとこちらも最新の公式ドキュメントに記載がある。 www.typescriptlang.org

要は TypeScript が型を絞り込めるようにコード内で条件分岐が必要だということ。例えばこういうコードでエラーになった場合、

function printId(id: number | string) {
  console.log(id.toUpperCase());
// Property 'toUpperCase' does not exist on type 'string | number'.
//  Property 'toUpperCase' does not exist on type 'number'.
}

下記のように条件分岐することで必要なメソッドも推論されて実行可能となる。

function printId(id: number | string) {
  if (typeof id === "string") {
    // In this branch, id is of type 'string'
    console.log(id.toUpperCase());
  } else {
    // Here, id is of type 'number'
    console.log(id);
  }
}

冒頭のコードに適用すると下記のような感じになる。type というプロパティをもたせて、それによって処理を分岐することでどちらかにしか存在しないプロパティも問題なく実行することが可能となる。

type Props1 = {
  type: "titleOnly"
  id: string
  title: string
}
type Props2 = {
  type: "hasOwner"
  id: string
  title: string
  owner: {
    id: string
    name: string
  }
}
type UnionType = Props1 | Props2

export type Props<T extends UnionType> = {
  params: T
}
export const SampleComponent = <T extends UnionType>({ params }: Props<T>) => {
  switch (params.type) {
    case "titleOnly":
      console.log(params.title)
      break
    case "hasOwner":
      console.log(params.owner.name)
      break
    default:
      console.log("Type property not found.")
  }
}

SampleComponent({
  params: {
    type: "hasOwner",
    id: "1",
    title: "sample",
    owner: { id: "2", name: "Imai" },
  },
})

公式ドキュメントにわかりやすい例えが載っていたのでここにも掲載しておく。

For example, if we had a room of tall people wearing hats, and another room of Spanish speakers wearing hats, after combining those rooms, the only thing we know about every person is that they must be wearing a hat.

たとえば、背の高い人が帽子をかぶっている部屋と、スペイン語を話す人が帽子をかぶっている部屋がある場合、それらの部屋を組み合わせた後、すべての人について知っているのは、帽子をかぶっていなければならないということだけです。(Google 翻訳)

まとめ

  • Union Types を使用した場合 Union として定義されるのは結合する Type の共通部分のみ
  • どちらかにしか存在しないメソッドやプロパティを使用する場合は条件分岐することで使用可能
  • 実装していると雰囲気で使ってしまうこともあるが、基本的なところは公式ドキュメントをちゃんと読んで理解してから使うようにする。
  • エラーが発生したときは問題をシンプルにして原因を特定する。(Generics 関係なかった)