Part 4 Union:型が一本に決まらない
この章のゴールは、型が一本に決まらないときの型Unionを導入することです。Rubyではifや三項演算子で枝ごとに別々の型を返すのが日常です。そのとき型を1本に決めつけず、「どちらか」としてまとめて持ちます。それがUnionです。
4-1. Union:型が一本に決まらない
Section titled “4-1. Union:型が一本に決まらない”こんなRubyを考えます。
x = rand < 0.5 ? 1 : "a"xの型はInteger?String? どちらにもなり得ます。こういうとき、型を一本に決めず「IntegerかStringのどちらか」という型にします。これが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] endendunionの小道具がやっているのは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])nilもConst[nil]というふつうの型として扱い、elseの無いifは「偽のときnil」をそのままUnionに混ぜます。だからc ? 1 : nilもif cond then 1 endも、素直に1 | nilです。
annotate/type_ofで確かめると、ちゃんとUnionが出ます。
type_of(parse("rand < 0.5 ? 1 : \"a\"")) # => 1 | "a"(両枝とも Const のまま 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 : 2でxは1 | 2です。ではx + 1の型は? この章の最小版(とPart 2の素朴なディスパッチ表)は、レシーバの型をclass_ofで1つのクラス名に丸めて表を引きます。Unionはクラス名に丸まらない(class_ofがnil)ので、表が引けず黙ってuntypedに倒れます。fail-softの出口ですが、せっかくの1 | 2の精度は捨ててしまいます。
実物のexe/chibirigorは、ここで一歩踏み込みます。Unionレシーバはメンバごとに表を引き、出てきた戻り型をType.unionで畳みます(lib/chibirigor/dispatch.rbのdispatch_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+1と1+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は型エラーです。でも実行時にはxがInteger側に転んでいれば動きます。だから全メンバで失敗したときだけ怒り、一部だけの失敗は黙ります(:maybe)。x + "a"のように(1 | 2)のどちらでも失敗する式だけが、診断1件になります。
未知メンバがいたらさらに保守的で、x = cond ? 1 : nilのx + 1はnil.+が表に無い時点で、Union全体をuntypedに倒します。一部でも型を見失えば、全体の精度を主張しません。
実物の挙動はtest/test_union_dispatch.rbが仕様兼サンプルです(レシーバ分配、引数の直積、全メンバ失敗時だけ怒る、未知メンバでuntyped、メンバ数予算でクラスに丸めるを網羅)。4-1のannotate出力(rand < 0.5 ? 1 : "a"が1 | "a")の続きとして、そのxにメソッドを送ると分配が起きると読んでください。手元のexe/chibirigorで(1 | 2) + 1が2 | 3と出る(章の最小版ならuntyped)のは、この分配がDispatch側に入っているからです。
4-2. この章のまとめ
Section titled “4-2. この章のまとめ”足したものは、型キャリア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 | nilc ? 1 : "a"はthen節が1、else節が"a"です。どちらか一方に決めつけず1 | "a"というUnionにまとめます。else側がnilでも同じく1 | nilです。
この章の三つの視点:
| 内容 | |
|---|---|
| ① 型理論 | 値が複数の型になり得る=ユニオン型(『しくみ』があえて避けた領域。『TAPL』も直接の章なし) |
| ② Ruby/RBS | 分岐で別々の型を返すのは日常。x = cond ? 1 : "a"もUser | nilも普通に書く |
| ③ Rigor実装の問題 | 一本に決めつけずUnionで持つ。決めつけない=後で困らない |
rand < 0.5 ? 1 : 2の型をannotateで確かめると1 | 2になる(両枝ともConstのまま)。 ではrand < 0.5 ? 1 : 1なら何になるか。unionの小道具が同じメンバをどう畳むかで説明せよ。- elseの無い
if cond\n 1\nendの型をannotateで確かめると1 | nilになる (実際のRubyが、else無しのifが偽のときnilを返すのに合わせている)。unionがこの2つをどうまとめるか、メンバの並びで説明せよ。 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.