Feature #22100
closedNative Union Types in Ruby
Description
Summary¶
Add a UnionType class to Ruby's standard library and extend Class#| to
construct one, enabling expressive, composable type-checking syntax throughout
the language.
String | Integer # => UnionType(Integer | String)
value.is_a?(String | Integer)
case value
when String | Integer then ...
end
Motivation¶
1. Type-checking sugar that every Ruby developer already writes by hand¶
Runtime type validation is ubiquitous in Ruby codebases. The current idioms
are verbose and inconsistent:
# Common patterns in the wild today
raise TypeError unless value.is_a?(String) || value.is_a?(Integer)
raise TypeError unless [String, Integer].any? { |t| value.is_a?(t) }
raise TypeError unless String === value || Integer === value
A union type collapses all of these into a single, readable expression:
raise TypeError unless value.is_a?(String | Integer)
This is not a niche use-case. Any method that accepts multiple types — a
common pattern in Ruby's own standard library — benefits immediately:
# Hypothetical standard library
def write(data)
raise TypeError, "expected String or IO" unless data.is_a?(String | IO)
...
end
The case/when integration comes for free because UnionType implements
===, making union branches in case expressions natural and zero-cost to
adopt.
2. RBS and Sorbet already model this concept; Ruby itself should too¶
Ruby's own type annotation language RBS uses | for union types as
first-class syntax:
def process: (String | Integer) -> void
Sorbet expresses the same idea with T.any:
sig { params(value: T.any(String, Integer)).void }
def process(value) = ...
Both tools have converged on the same semantic. Having the concept in static
annotations but not in runtime Ruby creates a gap: developers must translate
String | Integer from their type signatures into verbose is_a? chains by
hand, and the two can drift out of sync.
Sorbet requires the class constant instead, and T.nilable only covers a
single type — so a multi-type nullable needs the verbose form:
T.any(String, Integer, NilClass) # Sorbet — nil literal not accepted
T.nilable(T.any(String, Integer)) # Sorbet alternative, extra nesting
With a native UnionType the expression stays flat and readable:
String | Integer | nil # UnionType — matches RBS exactly
Comparison with dry-types sum types. dry-schema uses dry-types' |
operator for multi-type fields:
required(:value).value(Dry::Types['integer'] | Dry::Types['string'])
Dry::Types['integer'] is a Constrained<Nominal<Integer>> object — a
class check with no coercion, semantically equivalent to what UnionType
provides. For already-typed data (parsed JSON, domain objects) a native
UnionType would be a simpler drop-in:
required(:value).value(Integer | String) # hypothetical, with native UnionType
Construction-time optimization is also worth noting. A UnionType prunes
redundant members at construction: Integer | Numeric collapses to Numeric
immediately, so every subsequent === check is against the minimal set of
classes. User-space code using Array#any? cannot do this without re-running
the deduplication on every call. A native type is also a known, stable shape
that the VM could treat specially in the future — the same path that gave
Integer, Symbol, and true/false their fast paths.
3. Config-style type validation is a widespread, unsolved pattern¶
Many Ruby libraries and frameworks define configuration schemas as plain
hashes, with a :type key holding an array of valid classes:
# ActiveModel-style validators
validates :amount, type: [Integer, Float]
# Schema definitions (dry-schema, Grape, GraphQL-Ruby, etc.)
params do
requires :id, type: [String, Integer]
optional :meta, type: [Hash, NilClass]
end
# Home-grown config validation
SCHEMA = {
timeout: { type: [Integer, Float], default: 30 },
host: { type: [String, NilClass], default: nil },
}
Today these arrays have no standard protocol. Each library re-implements the
same loop:
Array(config[:type]).any? { |t| value.is_a?(t) }
A UnionType gives this pattern a first-class home. Libraries could accept
either an array or a UnionType transparently via ===, and authors could
write schemas that are self-documenting and immediately executable:
SCHEMA = {
timeout: { type: Integer | Float, default: 30 },
host: { type: String | NilClass, default: nil },
}
SCHEMA.each do |key, rule|
raise TypeError, "#{key} must be #{rule[:type]}" unless rule[:type] === config[key]
end
4. Literal-value sugar for the three Ruby singletons¶
Ruby has exactly three values that are singletons of their own class:
nil (NilClass), true (TrueClass), and false (FalseClass).
Because the literal and the class are interchangeable conceptually, the
| operator accepts all three as shorthand:
String | nil # => UnionType(String | nil) same as String | NilClass
String | true # => UnionType(String | true) same as String | TrueClass
String | false # => UnionType(String | false) same as String | FalseClass
# Common real-world pattern: nullable type
def greet(name)
raise TypeError unless name.is_a?(String | nil)
"Hello, #{name || "stranger"}!"
end
These three are the complete set. No other Ruby literal has a distinct
singleton class, so no further sugar is needed or planned.
Footgun note: writing nil | String returns true because NilClass#|
is the boolean OR operator. The sugar only works with the union type on the
left: String | nil. This mirrors how Ruby already treats nil | x today
and is a known trade-off.
Proposed additions¶
| Addition | Description |
|---|---|
UnionType class |
Immutable value object wrapping a sorted set of classes |
Class#| |
Returns UnionType.new(self, other); accepts nil, true, false as sugar |
UnionType#=== |
Enables case/when
|
Object#is_a? / kind_of?
|
Accept UnionType as argument |
Object#instance_of? |
Accept UnionType as argument |
UnionType#& |
Intersection of two union types |
UnionType#cover? |
True if a class is covered by the union |
UnionType includes Enumerable
|
Full iteration over member classes |
Reference implementation¶
A working gem implementation is available at
https://github.com/bogdan/ruby-union-type
Compatibility¶
Class#| is not currently defined in Ruby, so no existing code is broken.
Object#is_a? is extended in a backwards-compatible way: non-UnionType
arguments fall through to the original C implementation.