SDKs
Open enums and smart unions in Ruby SDKs
Daniel Kovacs
February 11, 2026 - 5 min read
Ruby SDKs generated by Speakeasy now support open enums and smart union deserialization. If you ship a Ruby SDK for your API, these features let older SDK versions handle new enum values and union variants without crashing.
This post covers what these features do, how they work in Ruby, and how to enable them.
The problem: closed enums break on new values
Enums in OpenAPI specs are closed by default. When you add a new value to an enum, older SDKs that don’t know about that value will fail during deserialization.
Consider a payment API with a status field:
PaymentStatus:
type: string
enum:
- pending
- completed
- failedYour Ruby SDK represents this as a class with T::Enum (Sorbet) or Crystalline::Enum:
class PaymentStatus < T::Enum
enums do
PENDING = new('pending')
COMPLETED = new('completed')
FAILED = new('failed')
end
endThis works until you add refunded to your API. Users on older SDK versions try to deserialize a response containing "refunded" and get an error:
PaymentStatus.deserialize('refunded')
# => raises "Invalid value for enum: refunded"The entire response fails to parse, not just the status field.
Open enums: accept what you don’t recognize
Open enums solve this by accepting unknown values instead of rejecting them. In Ruby, the approach is straightforward: enum values are string constants, and unknown values pass through as raw strings.
Mark an enum as open in your OpenAPI spec:
PaymentStatus:
type: string
x-speakeasy-unknown-values: allow
enum:
- pending
- completed
- failedThe generated Ruby SDK uses Crystalline::Enum with open!, which accepts unknown values on deserialize instead of raising:
class PaymentStatus
include ::Crystalline::Enum
enums do
PENDING = new('pending')
COMPLETED = new('completed')
FAILED = new('failed')
end
open!
endWhen the API returns "refunded", the SDK doesn’t crash. The value deserializes into an enum instance wrapping the raw value:
payment = sdk.payments.get(id: payment_id)
status = payment.status
status.known? # => false (it's an unknown value)
status.serialize # => "refunded"
case status
when Shared::PaymentStatus::PENDING
puts "Payment is pending"
when Shared::PaymentStatus::COMPLETED
puts "Payment completed"
when Shared::PaymentStatus::FAILED
puts "Payment failed"
else
puts "New status: #{status.serialize}" unless status.known?
endOpen enums use value-based equality, so comparisons with == work naturally between known constants and deserialized instances. The known? method tells you whether the value maps to a declared constant.
Enabling open enums globally
Instead of marking individual enums, you can make all response enums open by default:
ruby:
forwardCompatibleEnumsByDefault: trueThis applies to enums used in response types. Enums with an explicit x-speakeasy-unknown-values extension or special patterns (days of the week, months) are not affected.
Request enums stay closed
Open enums only apply to response deserialization. Request enums remain closed so you get validation when sending data to the API.
Smart union deserialization
Unions (oneOf in OpenAPI) present a different challenge. When the SDK receives a JSON response, it needs to figure out which union variant the data belongs to. The default strategy, left-to-right, tries each variant in order and returns the first valid match. This works when variants have distinct required fields, but fails when one variant is a subset of another.
Consider a notification system:
Notification:
oneOf:
- $ref: '#/components/schemas/EmailNotification'
- $ref: '#/components/schemas/SlackNotification'
EmailNotification:
type: object
required: [recipient, subject]
properties:
recipient:
type: string
subject:
type: string
SlackNotification:
type: object
required: [recipient, subject, channel]
properties:
recipient:
type: string
subject:
type: string
channel:
type: stringWith left-to-right, a Slack notification might deserialize as an EmailNotification because EmailNotification is listed first and all its required fields are present in the Slack payload. The channel field gets silently dropped.
The populated-fields strategy
The populated-fields strategy fixes this. Instead of stopping at the first valid match, it tries all variants and picks the one with the most matching fields:
ruby:
unionStrategy: populated-fieldsNow the SDK evaluates both variants:
EmailNotificationmatches 2 fields (recipient,subject)SlackNotificationmatches 3 fields (recipient,subject,channel)
SlackNotification wins because it matches more fields. If there’s a tie, the variant with the fewest inexact matches (like coerced enum values) is preferred.
Here’s how you consume union types in Ruby:
result = sdk.notifications.get(id: notification_id)
notification = result.notification
case notification
when Shared::EmailNotification
puts "Email to #{notification.recipient}: #{notification.subject}"
when Shared::SlackNotification
puts "Slack ##{notification.channel}: #{notification.subject}"
else
puts "Unknown notification type"
endRuby’s case/when works naturally here since it uses === for matching, which checks class membership for class comparisons.
Configuration reference
Both features are configured in your gen.yaml under the ruby section:
ruby:
forwardCompatibleEnumsByDefault: true
unionStrategy: populated-fields| Setting | Default | Description |
|---|---|---|
forwardCompatibleEnumsByDefault | true | Generate response enums as open by default. Enums with explicit x-speakeasy-unknown-values or special patterns are not affected. |
unionStrategy | populated-fields | populated-fields picks the variant with the most matching fields. left-to-right returns the first valid match. |
For per-schema control, use the x-speakeasy-unknown-values extension in your OpenAPI spec:
PaymentStatus:
type: string
x-speakeasy-unknown-values: allow # or 'disallow'
enum:
- pending
- completed
- failedPutting it together
With both features enabled, your Ruby SDK handles three common API evolution scenarios:
- New enum values: Unknown values pass through as strings instead of causing deserialization errors
- New union variants: Smart matching picks the best-fit variant instead of relying on declaration order
- Extended existing types: When existing variants gain new optional fields, the SDK selects the correct variant based on populated field count
Your SDK users don’t need to change their code. Older versions continue working as the API evolves.
Related reading:
- Forward compatibility in Speakeasy SDKs: Full reference for all languages
- Ruby configuration reference: All Ruby SDK config options
- Open enums in Java: The same pattern in a statically-typed language