コンテンツにスキップ

Part 4 Union:型が一本に決まらない

この章のゴールは、型が一本に決まらないときの型Unionを導入することです。Rubyではifや三項演算子で枝ごとに別々の型を返すのが日常です。そのとき型を1本に決めつけず、「どちらか」としてまとめて持ちます。それがUnionです。


4-1. Union:型が一本に決まらない

Section titled “4-1. Union:型が一本に決まらない”

こんなRubyを考えます。

x = rand < 0.5 ? 1 : "a"

xの型はIntegerString? どちらにもなり得ます。こういうとき、型を一本に決めず「IntegerStringのどちらか」という型にします。これがUnionです。

module Type
Union = Data.define(:members) do
def to_s = members.map(&:to_s).join(" | ") # 例: "Integer | String"
end
module_function
# 型をまとめる小さな道具。入れ子をならし、重複を消す
def union(types)
flat = types.flat_map { |t| t.is_a?(Union) ? t.members : [t] }.uniq
flat.size == 1 ? flat.first : Union[flat]
end
end

unionの小道具がやっているのは2つだけです。入れ子をならすUnionの中にUnionが来たら平らにする)、そして重複を消す(同じ型が2度出てきたら1つにする)です。まとめた結果がメンバ1個になったら、わざわざUnionで包まず、その型そのものを返します(Integer | IntegerはただのInteger)。

if(三項演算子もPrismでは同じIfNode)の型は、then節とelse節の型をまとめたものにします。

when Prism::NilNode
Type::Const[nil] # nil リテラルの型。Union のメンバに普通に並ぶ
when Prism::IfNode
then_type = type_of(node.statements.body.last, scope, diagnostics)
else_type =
if node.subsequent # else(や elsif)があるか
type_of(node.subsequent.statements.body.last, scope, diagnostics)
else
Type::Const[nil] # else が無ければ、偽のとき nil ― 実際の Ruby に合わせる
end
Type.union([then_type, else_type])

nilConst[nil]というふつうの型として扱い、elseの無いifは「偽のときnil」をそのままUnionに混ぜます。だからc ? 1 : nilif cond then 1 endも、素直に1 | nilです。

annotatetype_ofで確かめると、ちゃんとUnionが出ます。

type_of(parse("rand < 0.5 ? 1 : \"a\"")) # => 1 | "a"(両枝とも Const のまま union)

図4-1 if/三項の型(thenとelseをまとめてUnion)

▼ 図4-1 if/三項の型(thenとelseをまとめてUnion、図5-1の逆向き)

  • ① 型理論:値が複数の型になり得るとき=ユニオン型(『しくみ』はあえて避けた領域)。
  • ② Rubyだと:分岐で別々の型を返すのは日常です。x = cond ? 1 : "a"は普通に書きます。
  • ③ Rigorだと:一本に決めずUnionで持ちます。決めつけないことが、後で困らないことにつながります。

4-1x. 発展:Unionレシーバへのメソッド送信(分配して畳む)

Section titled “4-1x. 発展:Unionレシーバへのメソッド送信(分配して畳む)”

x = cond ? 1 : 2x1 | 2です。ではx + 1の型は? この章の最小版(とPart 2の素朴なディスパッチ表)は、レシーバの型をclass_ofで1つのクラス名に丸めて表を引きます。Unionはクラス名に丸まらない(class_ofnil)ので、表が引けず黙ってuntypedに倒れます。fail-softの出口ですが、せっかくの1 | 2の精度は捨ててしまいます。

実物のexe/chibirigorは、ここで一歩踏み込みます。Unionレシーバはメンバごとに表を引き、出てきた戻り型をType.unionで畳みますlib/chibirigor/dispatch.rbdispatch_union)。

# Union レシーバの分配ディスパッチ。実行時はどのメンバにもなり得るので、
# メンバごとに dispatch して結果を union で畳む。
def dispatch_union(receiver_type, name, arg_types, node, diagnostics)
buffers = []
results = receiver_type.members.map do |member|
buffers << (buffer = [])
dispatch(member, name, arg_types, node, buffer) # メンバ 1 つずつ表を引く
end
diagnostics.concat(merge_member_diagnostics(buffers))
budgeted_union(results) # 結果を畳む(重なれば 1 つに)
end

引数側にUnionが来ても同じ発想です。2-7の定数畳み込み段は、引数をメンバの直積に展開して組ごとに畳みます(const_combinations)。1 + (1 | 2)なら1+11+2を両方計算して2 | 3になります。

実際に動かすと、レシーバ分配も引数分配もこう出ます(exe/chibirigor annotate)。

x = cond ? 1 : 2 ; x + 1 # 2: 2 | 3 (レシーバ (1|2) を分配して畳む)
a = 1 ; a + (cond ? 1 : 2) # 2: 2 | 3 (引数 (1|2) を直積に展開して畳む)
x = cond ? 1 : "a" ; x + 1 # 2: 2 | String (Integer 側は畳み、String 側は表の戻り型へ)

この挙動は誤検知ゼロの原則と地続きです。分配の結果が割れたらどうするか。x = cond ? 1 : "a"x + 1は、1 + 1は通り"a" + 1は型エラーです。でも実行時にはxInteger側に転んでいれば動きます。だから全メンバで失敗したときだけ怒り、一部だけの失敗は黙ります:maybe)。x + "a"のように(1 | 2)のどちらでも失敗する式だけが、診断1件になります。

未知メンバがいたらさらに保守的で、x = cond ? 1 : nilx + 1nil.+が表に無い時点で、Union全体をuntypedに倒します。一部でも型を見失えば、全体の精度を主張しません。

実物の挙動はtest/test_union_dispatch.rbが仕様兼サンプルです(レシーバ分配、引数の直積、全メンバ失敗時だけ怒る、未知メンバでuntyped、メンバ数予算でクラスに丸めるを網羅)。4-1のannotate出力(rand < 0.5 ? 1 : "a"1 | "a")の続きとして、そのxにメソッドを送ると分配が起きると読んでください。手元のexe/chibirigor(1 | 2) + 12 | 3と出る(章の最小版ならuntyped)のは、この分配がDispatch側に入っているからです。


足したものは、型キャリアUnionひとつ、まとめ道具union、そしてIfNodeの型付け(then節とelse節の型をunionする)です。unionの小道具は「入れ子をならす、重複を消す」の2つだけです。これで「型が一本に決まらない」Rubyを、型のレベルで素直に表せるようになりました。

動かすとこうなります。

x_int_str = Chibirigor.annotate("x = c ? 1 : \"a\"\nx\n").last[:type]
x_int_nil = Chibirigor.annotate("x = c ? 1 : nil\nx\n").last[:type]
puts "c ? 1 : \"a\" -> #{x_int_str}"
puts "c ? 1 : nil -> #{x_int_nil}"
c ? 1 : "a" -> 1 | "a"
c ? 1 : nil -> 1 | nil

c ? 1 : "a"はthen節が1、else節が"a"です。どちらか一方に決めつけず1 | "a"というUnionにまとめます。else側がnilでも同じく1 | nilです。

この章の三つの視点:

内容
① 型理論値が複数の型になり得る=ユニオン型(『しくみ』があえて避けた領域。『TAPL』も直接の章なし)
② Ruby/RBS分岐で別々の型を返すのは日常。x = cond ? 1 : "a"User | nilも普通に書く
③ Rigor実装の問題一本に決めつけずUnionで持つ。決めつけない=後で困らない
  1. rand < 0.5 ? 1 : 2の型をannotateで確かめると1 | 2になる(両枝ともConstのまま)。 ではrand < 0.5 ? 1 : 1なら何になるか。unionの小道具が同じメンバをどう畳むかで説明せよ。
  2. elseの無いif cond\n 1\nendの型をannotateで確かめると1 | nilになる (実際のRubyが、else無しのifが偽のときnilを返すのに合わせている)。 unionがこの2つをどうまとめるか、メンバの並びで説明せよ。
  3. Union[[Integer, Union[[String, Integer]]]]unionに通すと何が返るか。 「入れ子をならす」「重複を消す」「メンバ1個なら包まない」の3つを順に当てはめて答えよ。

次章予告(Part 5):Unionは型を増やす操作でした。次章ではその逆、Unionを減らすナローイング(絞り込み)を作ります。if x.nil?のelse節で「ここのxはもうnilじゃない」と型を狭める、あの当たり前を型でも追えるようにします。偽はfalse/nilの2つだけ、narrowの実装、is_a?のdead branch、絞り込みの2つの掟、再代入でのリセットまで、そこで扱います。


この章の実装(演習の答え合わせにも)impls/dist/part4/lib

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.