明日から使えるTypeScriptの応用テクニックその2 -判別可能なUnion/Distribution編-
前回の記事の続きです。
今回は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") {
というタグを用いて条件分岐をしている部分で、animal
はDog
型に絞りこまれます。その結果として、条件分岐の中でanimal.barks()
に安全にアクセスでき、animal.meows()
はコンパイルエラーとなります。
反対にif (animal.type === "cat") {
というタグを用いて条件分岐をしている部分で、animal
はCat
型に絞りこまれます。その結果として、条件分岐の中で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
の型がうまく絞り込まれません。
罠②:分割代入、レスト構文の罠
最新の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の創設者)は、この挙動の改善に前向きではあるものの改善は容易ではないという認識のようです。
罠③:分割代入、別々の代入の罠
判別可能な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
にもある共通のプロパティですが、判別可能なタグとはなりえません。
Alive
のlifePoint: 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("我々は苦しむ、しかしなぜ?"),
})
これはコンパイルエラーにならず、実行時エラーになってしまうコードです。Dead
かAlive
かを判別可能ではないため、両方のプロパティを有するオブジェクトを受け入れてしまうのです。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("我々は苦しむ、しかしなぜ?"),
})
この場合、turnIntoGhost
もlookForMeaningOfLife
も両方undefinedではないとうことは、ありえないパターンです。turnIntoGhost
かlookForMeaningOfLife
か必ずどちらか一方を持ちます。両方持つということが不可能なように型的に保証されています。
したがって、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であるときのみ、T
とT2
が別の型になり、T2
はT
の部分型ではないと判断されます。その結果、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 | true
↓
true
という流れで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というパターンです。実際に使ってみます。
いい感じですね!
おわり
以上です。
判別可能なUnionは、非常に有効なパターンなので、ガンガン使っていきたいですね。
一方、Union Distributionの方は、ちょっとマニアックな知識で、「明日から使えるTypeScript」というテーマをやや逸脱してしまったかもしれません。IsUnion
やKeysOfUnion
やDistributiveOmit
のような型関数を実務で使うことはまずないと思います。しかし、知っておくとライブラリの型定義を読み解くことができ、なかなか便利です。
次の応用テクニックその3ではReactの型定義を覗いてみますが、今回のような型のパズルに馴染んでおくと、案外簡単に感じるかもしれません。ちなみに、そこで覗いてみるReactの型定義の中で、一箇所だけDistributiveOmit
のパターンがこっそり登場します。よければ探してみてください。
一旦ここまで。
読んでいただきありがとうございました!
(筆者はTypeScript初心者なので、もし間違いがありましたらお優しめにご指摘ください🙏)
その3に続きます☞
Discussion