Part 4 — Union: when a type doesn't settle on one
This chapter’s goal: introduce the type Union for when a type doesn’t settle on one. In
Ruby, returning a different type per branch of an if or a ternary is everyday. When that
happens, instead of forcing the type to one, we hold it together as “either one” — that’s
Union.
4-1. When a type doesn’t settle on one — Union
Section titled “4-1. When a type doesn’t settle on one — Union”Consider this Ruby:
x = rand < 0.5 ? 1 : "a"Is x’s type Integer? String? — it can be either. When this happens, instead of
forcing the type to one, we make it a type that says “either Integer or String.” That’s the
Union:
module Type Union = Data.define(:members) do def to_s = members.map(&:to_s).join(" | ") # e.g. "Integer | String" end
module_function
# A small tool that combines types: flatten nesting, drop duplicates def union(types) flat = types.flat_map { |t| t.is_a?(Union) ? t.members : [t] }.uniq flat.size == 1 ? flat.first : Union[flat] endendThe little union tool does just two things. Flatten nesting (when a Union shows up
inside a Union, level it out), and drop duplicates (when the same type appears twice, make
it one). If the combined result has a single member, it returns that type itself rather than
bothering to wrap it in a Union (Integer | Integer is just Integer).
The type of an if (a ternary is the same IfNode in Prism) becomes the combination of the
then-branch’s and else-branch’s types:
when Prism::NilNode Type::Const[nil] # the type of the nil literal. Sits in a Union member like any otherwhen Prism::IfNode then_type = type_of(node.statements.body.last, scope, diagnostics) else_type = if node.subsequent # is there an else (or elsif)? type_of(node.subsequent.statements.body.last, scope, diagnostics) else Type::Const[nil] # no else → nil when false, matching real Ruby end Type.union([then_type, else_type])We treat nil as an ordinary type, Const[nil], too, and an if with no else mixes “nil
when false” straight into the Union. So both c ? 1 : nil and if cond then 1 end are
plainly 1 | nil.
Check with annotate / type_of and a Union duly comes out:
type_of(parse("rand < 0.5 ? 1 : \"a\"")) # => 1 | "a" (both branches union as Const)▼ Figure 4-1 — the type of
if/ ternary: union the then and else branches (the reverse of Figure 5-1)
- ① Type theory: when a value can be more than one type = a union type (the area『しくみ』 deliberately avoided).
- ② In Ruby: returning different types per branch is everyday. You write
x = cond ? 1 : "a"normally. - ③ In Rigor: don’t fix it to one; hold it as a Union. Not deciding = no trouble later.
4-1x. A note: method sends to a Union receiver (distribute and fold)
Section titled “4-1x. A note: method sends to a Union receiver (distribute and fold)”With x = cond ? 1 : 2, x is 1 | 2. So what’s the type of x + 1? This chapter’s minimal
version (and Part 2’s naive dispatch table) rounds the receiver’s type to a single class name
with class_of and looks up the table. A Union doesn’t round to a class name (class_of is
nil), so the table can’t be looked up and it quietly falls to untyped — the fail-soft
exit. It doesn’t frighten, but it throws away the precision of that hard-won 1 | 2.
The real exe/chibirigor steps in here. A Union receiver looks up the table per member, and
folds the resulting return types with Type.union (dispatch_union in
lib/chibirigor/dispatch.rb):
# Distributive dispatch for a Union receiver. At run time it can be any member,# so dispatch per member and fold the results with 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) # look up the table for one member at a time end diagnostics.concat(merge_member_diagnostics(buffers)) budgeted_union(results) # fold the results (overlaps collapse to one)endA Union on the argument side is the same idea. The constant-folding stage of 2-7 expands
arguments into the product of members and folds per combination (const_combinations) — for
1 + (1 | 2), it computes both 1+1 and 1+2 and gets 2 | 3. Run for real, both receiver
distribution and argument distribution come out like this (exe/chibirigor annotate):
x = cond ? 1 : 2 ; x + 1 # 2: 2 | 3 (distribute receiver (1|2) and fold)a = 1 ; a + (cond ? 1 : 2) # 2: 2 | 3 (expand argument (1|2) into the product and fold)x = cond ? 1 : "a" ; x + 1 # 2: 2 | String (fold the Integer side; the String side goes to the table's return type)This behavior is continuous with the zero-false-positive principle. What to do when the
distribution splits — for x = cond ? 1 : "a", x + 1 passes for 1 + 1 and is a type
error for "a" + 1. But at run time, if x fell to the Integer side, it works. So we complain
only when all members fail, and stay quiet about a partial failure (:maybe). Only an
expression that fails for either of (1 | 2), like x + "a", becomes a single diagnostic.
If there’s an unknown member, we’re more conservative still: for x = cond ? 1 : nil,
x + 1 collapses the whole Union to untyped the moment nil.+ isn’t in the table (lose track of
even one type, and we don’t assert precision for the whole).
The real behavior’s spec-cum-samples is test/test_union_dispatch.rb (covering receiver
distribution, the argument product, complaining only when all members fail, untyped on an
unknown member, and rounding to a class on the member-count budget). Read it as the sequel to
4-1’s annotate output (rand < 0.5 ? 1 : "a" → 1 | "a"): send a method to that x and
distribution happens. Run exe/chibirigor yourself and (1 | 2) + 1 shows up as 2 | 3 (the
chapter’s minimal version would say untyped), because this distribution lives on the Dispatch
side.
4-2. This chapter’s summary
Section titled “4-2. This chapter’s summary”What we added is one type carrier, Union, the combining tool union, and the typing of
IfNode (which unions the then-branch’s and else-branch’s types). The little union tool is
just the two of “flatten nesting, drop duplicates.” With this, we can plainly express, at the
type level, Ruby where “a type doesn’t settle on one.”
Running it:
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" has then-branch 1 and else-branch "a". Rather than forcing it to one or the
other, we combine into a Union, 1 | "a". With nil on the else side it’s likewise 1 | nil.
This chapter’s three perspectives:
| Content | |
|---|---|
| ① Type theory | A value can be more than one type = a union type(the area『しくみ』deliberately avoided; TAPL has no direct chapter either) |
| ② Ruby / RBS | Returning different types per branch is everyday. You write both x = cond ? 1 : "a" and User | nil normally |
| ③ Rigor’s implementation problem | Don’t force it to one; hold it as a Union. Not deciding = no trouble later |
Exercises
Section titled “Exercises”- Checking the type of
rand < 0.5 ? 1 : 2withannotategives1 | 2(both branches stayConst). So what doesrand < 0.5 ? 1 : 1give? Explain via how theuniontool folds the same member. - Checking the type of the else-less
if cond\n 1\nendwithannotategives1 | nil(matching how real Ruby returnsnilwhen an else-lessifis false). Explain howunioncombines the two, in terms of the member order. - What does
Union[[Integer, Union[[String, Integer]]]]return throughunion? Answer by applying the three of “flatten nesting,” “drop duplicates,” and “don’t wrap a single member” in order.
Next chapter (Part 5): Union was an operation that grows a type. Next chapter we build the
reverse — narrowing, which shrinks a Union. In the else clause of if x.nil?, we tighten
the type to “the x here is no longer nil” — making that obvious move traceable in types too.
There we’ll cover: false is just false / nil; the narrow implementation; the dead branch of
is_a?; the two laws of narrowing; and resetting on reassignment.
This chapter’s implementation (and answer key for the exercises) →
impls/dist/part4/lib
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.