Skip to Content

SDKs

Open enums and smart unions in Ruby SDKs

Daniel Kovacs

Daniel Kovacs

February 11, 2026 - 5 min read

SDKs

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 - failed

Your 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 end

This 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 - failed

The 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! end

When 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? end

Open 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: true

This 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.

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: string

With 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-fields

Now the SDK evaluates both variants:

  1. EmailNotification matches 2 fields (recipient, subject)
  2. SlackNotification matches 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" end

Ruby’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
SettingDefaultDescription
forwardCompatibleEnumsByDefaulttrueGenerate response enums as open by default. Enums with explicit x-speakeasy-unknown-values or special patterns are not affected.
unionStrategypopulated-fieldspopulated-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 - failed

Putting it together

With both features enabled, your Ruby SDK handles three common API evolution scenarios:

  1. New enum values: Unknown values pass through as strings instead of causing deserialization errors
  2. New union variants: Smart matching picks the best-fit variant instead of relying on declaration order
  3. 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:

Last updated on

Build with
confidence.

Ship what's next.