Issue #22100 has been updated by byroot (Jean Boussier).
if you prefer is_a?, why not just extending this method to accept multiple arguments?
That is something I wanted many times.
UnionType
My concern with this proposal, is that it's purely a dynamic/runtime declaration, with little to no way for the compiler to constantize these. So: ```ruby raise TypeError unless value.is_a?(Integer | Float | nil | false) ``` Will have to allocate multiple `UnionType` on every execution: first `UnionType(Integer, Float)`, then `UnionType(Integer, Float, nil)`, and finally `UnionType(Integer, Float, nil, false)`, so 3 allocations and 4 method calls for something that is functionally a constant. And given this is intended for type checking, it would likely end up used in lots of code, making Ruby even more allocation heavy than it already is. There might be some trickery we could pull in the compiler to try to optimize/cache this, but we'd need to ensure `#|` hasn't been redefined, etc. ---------------------------------------- Feature #22100: Native Union Types in Ruby https://bugs.ruby-lang.org/issues/22100#change-117547 * Author: bogdan (Bogdan Gusiev) * Status: Feedback ---------------------------------------- ## 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/