👻

明日から使えるTypeScriptの応用テクニックその2 -判別可能なUnion/Distribution編-

2023/10/31に公開

前回の記事の続きです。

https://zenn.dev/t_keshi/articles/tips-typescript-1

今回はMapped TypesとConditional Typesを踏まえた上で、Discriminated Union Typeについて見ていきます。また、Unionついでに、Union DistributionというUnionの持つ特殊な挙動についても取り上げます。

Discriminated Union Type

Discriminated Union Typeは、「判別共用体」や「判別可能なUnion型」などと訳され、実装的な側面を強調するときには「タグ付きユニオン」と呼ばれることもあります。個人的には、「判別可能なUnion」と呼ぶのが最も好みです。

これは、共通して持つプロパティ(タグ)を用いてユニオン型の構成要素を判別するパターンです。具体的には次のようなコードです。

type Dog = {
  type: "dog"
  barks: () => void
}
type Cat = {
  type: "cat"
  meows: () => void
}

const makeSounds = (animal: Dog | Cat) => {
  if (animal.type === "dog") {
    animal.meows() // コンパイルエラーが起こる
    animal.barks()
  }

  if (animal.type === "cat") {
    animal.meows()
    animal.barks() // コンパイルエラーが起こる
  }
}

このコードでは、DogにもCatにもtypeが含まれるので、typeが共通して持つプロパティ(タグ)です。タグがあるので、それを利用して型の絞りこみを行うことができます。

if (animal.type === "dog") {というタグを用いて条件分岐をしている部分で、animalDog型に絞りこまれます。その結果として、条件分岐の中でanimal.barks()に安全にアクセスでき、animal.meows()はコンパイルエラーとなります。

反対にif (animal.type === "cat") {というタグを用いて条件分岐をしている部分で、animalCat型に絞りこまれます。その結果として、条件分岐の中でanimal.meows()に安全にアクセスでき、animal.barks()はコンパイルエラーとなります

判別可能なUnion型の罠

この判別可能なUnion型は、TypeScriptを用いた安全な開発を行う上で非常に有効です。使いこなすと、条件分岐の部分で手厚い形のサポートを受けることができる、そんな強力な設計パターンと言えるでしょう。

しかし一方で、判別可能なUnion型にはいくつかの陥りやすい罠があります。特に判別可能なUnion型と分割代入を一緒に用いたときに起こりがちです。自分はけっこう嵌ってしまって時間を溶かしたことがあるのでぜひ気をつけてください。

罠①:分割代入、バージョン違いの罠

一つ目の注意点は、TypeScriptのバージョンによって変数を分割代入したときの挙動がけっこう異なることです。

分割代入された変数の判別可能なUnion型は、TypeScript4.6でサポートされるようになりました。そのためTypeScriptのバージョンが古い場合には、分割代入するとうまく型の絞り込みが行われないケースがあります。

具体的には、次のようなケースです。

type Dog = {
  type: "dog"
  voice: "bowwow!"
}
type Cat = {
  type: "cat"
  voice: "meow"
}

const makeSounds = (animal: Dog | Cat) => {
  const { type, voice } = animal

  if (type === "dog") {
    const v = voice // "bowwow!"に絞り込まれる
  }

  if (type === "cat") {
    const v = voice // "meow"に絞り込まれる
  }
}

上記コードにおいて、変数vはTypeScript4.6以降だと、ちゃんと"bowwow!"か"meow"のどちらか絞り込まれます。しかし、TypeScriptのバージョンが古い場合は、以下のように変数vの型がうまく絞り込まれません。

legacy-typescript

罠②:分割代入、レスト構文の罠

最新のTypeScriptのバージョンであっても、分割代入においてレスト構文を用いる場合、判別可能なUnion型は絞り込まれません。具体的には、

type Dog = {
  type: "dog"
  voice: "bowwow!"
}
type Cat = {
  type: "cat"
  voice: "meow"
}

const makeSounds = (animal: Dog | Cat) => {
  const { type, ...rest } = animal

  if (type === "dog") {
    const v = rest
  }

  if (type === "cat") {
    const v = rest
  }
}

というコードがあった場合、レスト構文を使っているため変数vの型がうまく絞り込まれません。変数vの型は、

{
    voice: "bowwow!";
} | {
    voice: "meow";
}

というUnion型のままになってしまいます。
次のIssueを読む限り、アンダース・ヘルスバーグ氏(TypeScriptの創設者)は、この挙動の改善に前向きではあるものの改善は容易ではないという認識のようです。

https://github.com/microsoft/TypeScript/issues/46680

罠③:分割代入、別々の代入の罠

判別可能なUnion型を有効にするには、分割代入を同じところで行わなければなりません。したがって、次のようなコードは適切に型が絞りこまれません。

type Dog = {
  type: "dog"
  voice: "bowwow!"
}
type Cat = {
  type: "cat"
  voice: "meow"
}

const makeSounds = (animal: Dog | Cat) => {
  const { type } = animal
  const { voice } = animal

  if (type === "dog") {
    const v = rest
  }

  if (type === "cat") {
    const v = rest
  }
}

ここまでで判別可能なUnion型について見てきました。ありがちな罠にさえ嵌まらなければ、非常に便利で安全性を与えるものです。
次は判別可能なUnion型の限界とその代替案について述べてみたいと思います。

無いかもしれないプロパティ

判別可能なUnion型は便利ですが、Union型に共通のプロパティがあったからといって必ずしも使えるものではありません。例えば、次のコードはどうでしょうか。

type Dead = {
  lifePoint: 0
  turnIntoGhost: () => void
}

type Alive = {
  lifePoint: number
  lookForMeaningOfLife: () => void
}

type DeadOrAlive = Dead | Alive

const doSomething = (deadOrLive: DeadOrAlive) => {
  if (deadOrLive.lifePoint === 0) {
    deadOrLive.turnIntoGhost()
  }
}

この場合、lifePointとはDeadにもAliveにもある共通のプロパティですが、判別可能なタグとはなりえません。
AlivelifePoint: numberという定義において、0も立派にnumberを満たす値です。故に、lifePointが0だからといってDeadには絞りこめないのです。そのため、上記コードはコンパイルエラーとなります。

代替案の一つとしては、新しい判別可能なプロパティを生やすことが挙げられます。例えば、

type Dead = {
  status: "dead",
  lifePoint: 0
  turnIntoGhost: () => void
}
type Alive = {
  status: "alive"
  lifePoint: number
  lookForMeaningOfLife: () => void
}

こうしてやれば今度はstatusが判別可能なキーになります。
しかし、型のためだけに無駄にプロパティを生やすのもなぁ...と思われる方も少なくないのではないでしょうか。そこで別の方法も考えてみたいと思います。

in演算子を用いる方法

こんなとき、TypeScriptのin演算子(TypeScript Deep Dive日本語版による解説)は割とカジュアルに使えて助かります。例えば、

type Dead = {
  lifePoint: 0
  turnIntoGhost: () => void
}

type Alive = {
  lifePoint: number
  lookForMeaningOfLife: () => void
}

type DeadOrAlive = Dead | Alive

const doSomething = (deadOrAlive: DeadOrAlive) => {
  if ("turnIntoGhost" in deadOrAlive) {
    deadOrAlive.turnIntoGhost()
  }
}

こうするとコンパイルエラーはなくなります。しかし、"turnIntoGhost"という文字列を打つときに型の補完が効くわけでは無いので、うっかりタイポしてしまう可能性はあります。

それだけなら、in演算子を使おうが大した問題ではないのですが、もう一つ注意すべき点があります。

例えば、人によっては生きながらにして死んでいたり、幽体離脱できる人もいるかもしれません。そんな可能性を考慮したとき、次のコードは大丈夫でしょうか?

type Dead = {
  lifePoint: 0
  turnIntoGhost: () => void
}

type Alive = {
  lifePoint: number
  lookForMeaningOfLife: () => void
}

type DeadOrAlive = Dead | Alive

const doSomething = (deadOrAlive: DeadOrAlive) => {
  if ("turnIntoGhost" in deadOrAlive) {
    deadOrAlive.turnIntoGhost()
  }
  if ("lookForMeaningOfLife" in deadOrAlive) {
    deadOrAlive.lookForMeaningOfLife()
  }
  throw new Error("予期しない型です。")
}

doSomething({
  lifePoint: 0,
  turnIntoGhost: () => console.log("幽体離脱"),
  lookForMeaningOfLife: () => console.log("我々は苦しむ、しかしなぜ?"),
})

これはコンパイルエラーにならず、実行時エラーになってしまうコードです。DeadAliveかを判別可能ではないため、両方のプロパティを有するオブジェクトを受け入れてしまうのです。in演算子を使うときは、この点に気をつけないといけません。

そこで、もっと良い案があります。

存在しないプロパティをoptionalにするパターン

存在しないプロパティに?を使い、optionalのundefinedにするパターンがあります。次のようなコードになります。

type Dead = {
  lifePoint: 0
  turnIntoGhost: () => void
+ lookForMeaningOfLife?: undefined
}

type Alive = {
  lifePoint: number
  lookForMeaningOfLife: () => void
+ turnIntoGhost?: undefined
}

type DeadOrAlive = Dead | Alive
const doSomething = (deadOrAlive: DeadOrAlive) => {
  const { turnIntoGhost, lookForMeaningOfLife } = deadOrAlive
  if (turnIntoGhost) {
    turnIntoGhost()
  }
  if (lookForMeaningOfLife) {
    lookForMeaningOfLife()
  }
  throw new Error("予期しない型です。")
}

// ✅ コンパイルエラーにならない
doSomething({
  lifePoint: 0,
  turnIntoGhost: () => console.log("幽体離脱"),
})

// 🚨 コンパイルエラーになる
doSomething({
  lifePoint: 0,
  turnIntoGhost: () => console.log("幽体離脱"),
  lookForMeaningOfLife: () => console.log("我々は苦しむ、しかしなぜ?"),
})

この場合、turnIntoGhostlookForMeaningOfLifeも両方undefinedではないとうことは、ありえないパターンです。turnIntoGhostlookForMeaningOfLifeか必ずどちらか一方を持ちます。両方持つということが不可能なように型的に保証されています。
したがって、in演算子を使いたくなったらいつでも、このoptionalのパターンの方が良いのではないかと考えてみてください。

ちなみに、今回の無いかもしれないプロパティをoptionalに変える型の操作を、もっとテクニカルにやろうとするのであれば、

type KeysOfUnion<T> = T extends unknown ? keyof ObjectType : never; // ← 後ほど解説します
type StrictUnionHelper<T, TAll> = T extends any ? T & Partial<Record<Exclude<KeysOfUnion<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>

type Dead = {
  lifePoint: 0
  turnIntoGhost: () => void
}
type Alive = {
  lifePoint: number
  lookForMeaningOfLife: () => void
}

type DeadOrAlive = StrictUnion<Dead | Alive>

という実装がなくはないのですが、しかし、複雑な型の安売りをしてはいけないというのは前記事、明日から使えるTypeScriptの応用テクニックその1 -Mapped/ConditionalTypes編-で述べた通りです。下手な抽象化をするくらいなら、次の3つ目の手段の方がまだ良いかもしれません。

そもそも関数を分ける

判別可能なプロパティがないときの3つ目の解決策は、関数を分けてしまうことです。人よっては、単純すぎてつまらない案に思えるかもしれません。しかし、共通のプロパティも持たず、別々のプロパティを持つような二つのものを一つの関数で処理しようというのが土台間違いだってこともあります。個人的な経験からも、凝ったテクニックよりむしろ基本に忠実に、責務を意識して関数を分離し、ファットな関数を作らないというのがプログラミングの肝のような気がします。

Union Distribution

ここまで、判別可能なUnion型と無いかもしれないプロパティの適切な扱い方について見てきました。TypeScriptに慣れた方にとっては、どこかで聞いたことがあるような面白みのない内容だったかもしれません。

そこで、ここから先はもうちょとマニアックなTypeScriptの話を盛り込んでいこうと思います。せっかくUnionの話が出たので、Unionついでに次はUnion Distributionです。

Union Distributionの基本

Union Distributionとは、Union型の分配という意味です。
(めっちゃ余談ですが、"Discriminated"と"Distribution"って単語の雰囲気が似ていて、時々言い間違えるのはTypeScriptあるあるじゃないですか...?)

Union型の分配とは、Conditional TypesやMapped Typesと一緒に用いたときに発生する特殊な挙動のことです。Union分配が発生する条件は色々と複雑なんですが、特にUnionが型変数であるときのみ発生するというポイントが重要です。

今のところ意味不明な説明ですが、具体例を見るとわかりやすいです。

type BoxedValue<T> = { value: T };
type DistributedBoxedValue = BoxedValue<string | number>;

ここにおいて、DistributedBoxedValueはどんな型になるでしょうか。普通に考えたら、{ value: string | number }になりそうな気がしませんか?

実は、これがそうはならないというのが、Union分配の面白いところです。

この場合、BoxedValue<T>に渡された型変数TがUnion型なので、Union分配の発生条件を満たしています。Union分配が起きると、BoxedValue<string> | BoxedValue<number>のように型変数が分配されてから型の計算が行われます。

そうすると、BoxedValue<string>{ value: string }で、BoxedValue<number>{ value: number }なので、最終的に答えは{ value: string } | { value: number }になります。

整理すると、

BoxedValue<string | number>
↓
BoxedValue<string> | BoxedValue<number>{ value: string } | { value: number }

という流れです。これがConditional TypesにおけるUnion Distributionの挙動です。

しかし、どうでしょう、便利そうですけど時には迷惑もなりそうな代物じゃないでしょうか? 逆に、Union Distributionしたくないってときは、それを回避する術はないんでしょうか?

Union Distribution回避

実は意外と簡単に分配を回避できます。Union分配は、型変数がUnionのときにのみ起こる挙動なので、何らかの方法で型変数をUnionじゃないものにしてあげればいいんです。

例えば、次のように[]で包めば、Unionは配列になるので、Union Distributionが起きません。

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

type ArrOfStrOrNum = ToArrayNonDist<string | number>;

一見、ハッキーなコードのようにも思えますが、上記はTypeScriptの公式サイトから持ってきた正当な方法です。通常、Union分配は望ましい挙動なので回避する必要はないですが、どうしても回避したい場合はこの方法を使いましょう。

IsUnion

Union Distributionの活用例について、TypeScript のいろんな型テクニックという記事に面白いコードがあったので、ここでちょっと紹介させていただきます。
こんなコード↓です。

type IsUnion<T, T2 = T> = T extends T2
  ? [T2] extends [T]
    ? false
    : true
  : never

このコードにおいてIsUnionは、Union DistributionとUnion Distribution回避の手法を用いて、型変数がUnionなのかそうでないのか判定するユーティリティ型関数です。
発想としては、

`T extends U ? X : Y``[T] extends [U] ? X : Y`の間に違いが生じる
↓
Union Distributionが起きている
↓
TはUnionである

というロジックです。

詳しい説明は元記事を読んでほしいのですが、まず<T, T2 = T>という部分でわざとTを複製しています。次にT extends T2という部分で、TがUnionであれば、Tは分配されます。

反対にT2は、[T2] extends [T] ? false : trueというところにおいて、Union Distribution回避をしているので分配されません。

だから、TがUnionであるときのみ、TT2が別の型になり、T2Tの部分型ではないと判断されます。その結果、trueが返ります。こういう仕組みで、Unionかそうでないかを判別できるのです。

具体的に考えるとさらにわかりやすいです。例えばstring | numberというUnion型を渡すと、

IsUnion<string | number, string | number>IsUnion<string, string | number> | IsUnion<number, string | number>

((string | number) extends string ? false : true) | ((string | number) extends number ? false : true)true | truetrue

という流れでtrueが返ってきます。
仕組みがわかるとなかなか面白い型関数ではないでしょうか。

KeysOfUnion

IsUnionという愉快な型関数をご紹介しましたが、もう一つだけUnion分配の興味深い例を挙げさせてください。まず、次のようなコードがあったと仮定します。

type AB = {
  a: string
  b: string
}
type BC = {
  b: string
  c: string
}
type ABorBC = AB | BC

type KeysOfABorBC = keyof ABorBC

この場合、KeysOfABorBCの型はどうなるでしょうか?

実はこれ、"a" | "b" | "c"ではなく"b"になります。ABとBCが共通して持っているプロパティのキーだけが、Unionのキーとなります。TypeScriptの仕様として、keyofの対象がUnionだった場合、その中の共通して存在するキーだけが取り出されます。

個人的には、TypeScriptのこの挙動は安全で、害のないものだと考えます。しかし、共通していないプロパティのキーまで含めて全部ほしいときはどうすればいいでしょうか?

そこで登場するのがKeysOfUnionです。具体的には、次のような型関数です。

type KeysOfUnion<ObjectType> = ObjectType extends unknown
  ? keyof ObjectType
  : never;

この型関数は、次のように使います。

type AB = {
  a: string
  b: string
}
type BC = {
  b: string
  c: string
}
type ABorBC = AB | BC

type AllKeys = KeysOfUnion<AB | BC>

このとき、AllKeysは今我々が求めていた型、"a" | "b" | "c"となります。どうしてそうなるのでしょうか。

これもUnion分配です。この場合、ObjectTypeがUnion型の型変数となるので、Union分配の発生条件を満たします。Unionが分配されると、keyof ABが"a" | "b"、keyof BCが"b" | "c"です。つまり、keyof AB | keyof BCは"a" | "b" | "b" | "c"となるので、最終的に返ってくる型は"a" | "b" | "c"となります。意外と簡単ですね。

Distributive Omit

Distributionついでに、Distributive Omitというパターンをご紹介します。

Omitという型関数は、TypeScriptユーザーにとって非常に馴染みの深いものでしょう。しかし、OmitにUnion型のオブジェクトを渡したとき、思いもよらぬ挙動を起こすことはご存知でしょうか。具体的には、次のコードを見てください。

type ABCorAB =
  | {
      a: string;
      b: string;
      c: string;
    }
  | {
      a: string;
      b: string;
    };

type Expect = Omit<ABCorAB, "a">

このときExpect{ b: string; c: string } | { b: string }になってほしいと思うかもしれません。しかし、実際には{ b: string; }になります。

どうしてかというのを順を追って説明すると、まずOmitの型定義は、

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

となっています。これはいわばPickの反転術式、キーに指定したもの以外をPickする型関数だといえます。して、ここで問題なのは、↑のExclude<keyof T, K>です。先ほどのKeysOfUnionのところで、keyof ABorBCが"b"になることはすでに述べました。

TypeScriptの仕様として、keyofの対象がUnionだった場合、その中の共通して存在するキーだけが取り出されます。

ならば、この場合のkeyof T、つまりkeyof ABCorABは、"a" | "b"を取り出すことになります。"a" | "b"の中から"a"をExcludeすると、当然結果は"b"です。最後に、ABCorABから"b"をPickすると、答えは{ b: string; }でファイナルアンサーです。

結局のところ原因は、type ABorBC = AB | BCが"b"であることと同じです。そして、原因が同じならば、対処方法も同じです。ということで、早速Union分配してみましょう。

type DistributiveOmit<T, K extends KeysOfUnion<T>> = T extends T ? Omit<T, K> : never;

これがUnion型であっても期待通りにOmitするDistributive Omitというパターンです。実際に使ってみます。

distributive-omit

いい感じですね!

おわり

以上です。
判別可能なUnionは、非常に有効なパターンなので、ガンガン使っていきたいですね。

一方、Union Distributionの方は、ちょっとマニアックな知識で、「明日から使えるTypeScript」というテーマをやや逸脱してしまったかもしれません。IsUnionKeysOfUnionDistributiveOmitのような型関数を実務で使うことはまずないと思います。しかし、知っておくとライブラリの型定義を読み解くことができ、なかなか便利です。

次の応用テクニックその3ではReactの型定義を覗いてみますが、今回のような型のパズルに馴染んでおくと、案外簡単に感じるかもしれません。ちなみに、そこで覗いてみるReactの型定義の中で、一箇所だけDistributiveOmitのパターンがこっそり登場します。よければ探してみてください。

一旦ここまで。
読んでいただきありがとうございました!
(筆者はTypeScript初心者なので、もし間違いがありましたらお優しめにご指摘ください🙏)

その3に続きます☞

https://zenn.dev/t_keshi/articles/tips-typescript-3

GitHubで編集を提案

Discussion