Issue #22100 has been reported by bogdan (Bogdan Gusiev). ---------------------------------------- Feature #22100: Native Union Types in Ruby https://bugs.ruby-lang.org/issues/22100 * Author: bogdan (Bogdan Gusiev) * Status: Open ---------------------------------------- ## 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. ```ruby 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: ```ruby # 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: ```ruby 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: ```ruby # 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: ```rbs def process: (String | Integer) -> void ``` **Sorbet** expresses the same idea with `T.any`: ```ruby 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: ```ruby 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: ```ruby String | Integer | nil # UnionType — matches RBS exactly ``` **Comparison with dry-types sum types.** dry-schema uses dry-types' `|` operator for multi-type fields: ```ruby 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: ```ruby 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: ```ruby # 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: ```ruby 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: ```ruby 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: ```ruby 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. -- https://bugs.ruby-lang.org/