Project

General

Profile

Actions

Feature #22100

closed

Native Union Types in Ruby

Feature #22100: Native Union Types in Ruby

Added by bogdan (Bogdan Gusiev) 13 days ago. Updated 13 days ago.

Status:
Feedback
Assignee:
-
Target version:
-
[ruby-core:125664]

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.

Actions

Also available in: PDF Atom