Ash.Type.Union (ash v3.5.24)

View Source

A union between multiple types, distinguished with a tag or by attempting to validate.

Union types allow you to define attributes that can hold values of different types. There are two main strategies for distinguishing between types:

  1. Tagged unions - Uses a specific field (tag) and value (tag_value) to identify the type
  2. Untagged unions - Attempts to cast the value against each type in order until one succeeds

Basic Usage

Define a union type in an attribute:

attribute :content, :union,
  constraints: [
    types: [
      text: [type: :string],
      number: [type: :integer],
      flag: [type: :boolean]
    ]
  ]

Values are wrapped in an %Ash.Union{} struct with :type and :value fields:

# Reading union values
%Ash.Union{type: :text, value: "Hello"}
%Ash.Union{type: :number, value: 42}

Tagged Unions

Tagged unions use a discriminator field to identify the type. This is more reliable but requires the data to include the tag field:

attribute :data, :union,
  constraints: [
    types: [
      user: [
        type: :map,
        tag: :type,
        tag_value: "user"
      ],
      admin: [
        type: :map,
        tag: :type,
        tag_value: "admin"
      ]
    ]
  ]

Input data must include the tag field:

# Valid inputs
%{type: "user", name: "John", email: "john@example.com"}
%{type: "admin", name: "Jane", permissions: ["read", "write"]}

Tag Options

  • tag - The field name to check (e.g., :type, :kind, :__type__)
  • tag_value - The expected value for this type (string, atom, or nil)
  • cast_tag? - Whether to include the tag in the final value (default: true)

When cast_tag?: false, the tag field is removed from the final value.

Untagged Unions

Without tags, union types attempt to cast values against each type in order:

attribute :flexible, :union,
  constraints: [
    types: [
      integer: [type: :integer],
      string: [type: :string]
    ]
  ]

Order matters! The first successful cast wins:

# "42" would be cast as :integer (42), not :string ("42")
# if integer comes first in the types list

Mixed Tagged and Untagged Types

You can mix tagged and untagged types within a single union. Tagged types are checked first by their tag values, and if no tagged type matches, untagged types are tried in order:

attribute :flexible_data, :union,
  constraints: [
    types: [
      # Tagged types - checked first by tag
      user: [
        type: :map,
        tag: :type,
        tag_value: "user"
      ],
      admin: [
        type: :map,
        tag: :type,
        tag_value: "admin"
      ],
      # Untagged types - tried in order if no tag matches
      number: [type: :integer],
      text: [type: :string]
    ]
  ]

This allows for both explicit type identification (via tags) and fallback casting:

# Tagged - uses :type field to determine it's a user
%{type: "user", name: "John"}

# Untagged - tries :integer first, then :string
42        # -> %Ash.Union{type: :number, value: 42}
"hello"   # -> %Ash.Union{type: :text, value: "hello"}

Storage Modes

Union values can be stored in different formats:

:type_and_value (default)

Stores as a map with explicit type and value fields:

# Stored as: %{"type" => "text", "value" => "Hello"}

:map_with_tag

Stores the value directly (requires all types to have tags):

# Stored as: %{"type" => "user", "name" => "John", "email" => "john@example.com"}

constraints: [
  storage: :map_with_tag,
  types: [
    user: [type: :map, tag: :type, tag_value: "user"],
    admin: [type: :map, tag: :type, tag_value: "admin"]
  ]
]

Embedded Resources

Union types work seamlessly with embedded resources:

attribute :contact_info, :union,
  constraints: [
    types: [
      email: [
        type: EmailContact,
        tag: :type,
        tag_value: "email"
      ],
      phone: [
        type: PhoneContact,
        tag: :type,
        tag_value: "phone"
      ]
    ]
  ]

Arrays of Unions

Union types support arrays using the standard {:array, :union} syntax:

attribute :mixed_data, {:array, :union},
  constraints: [
    items: [
      types: [
        text: [type: :string],
        number: [type: :integer]
      ]
    ]
  ]

Advanced Input Formats

Union types support multiple input formats for flexibility:

Direct Union Struct

%Ash.Union{type: :text, value: "Hello"}

Tagged Map (when tags are configured)

%{type: "text", content: "Hello"}

Explicit Union Format

%{
  "_union_type" => "text",
  "_union_value" => "Hello"
}

# Or with the value merged in
%{
  "_union_type" => "text",
  "content" => "Hello"
}

Nested Unions

Unions can contain other union types. All type names must be unique across the entire nested structure:

types: [
  simple: [type: :string],
  complex: [
    type: :union,
    constraints: [
      types: [
        # Names must be unique - can't reuse 'simple'
        nested_text: [type: :string],
        nested_num: [type: :integer]
      ]
    ]
  ]
]

Loading and Calculations

Union types support loading related data when member types are loadable (like embedded resources):

# Load through all union types
query |> Ash.Query.load(union_field: :*)

# Load through specific type
query |> Ash.Query.load(union_field: [user: [:profile, :preferences]])

Error Handling

Union casting provides detailed error information:

  • Tagged unions: Clear errors when tags don't match expected values
  • Untagged unions: Aggregated errors from all attempted type casts
  • Array unions: Errors include index and path information for debugging

NewType Integration

Create reusable union types with Ash.Type.NewType:

defmodule MyApp.Types.ContactMethod do
  use Ash.Type.NewType,
    subtype_of: :union,
    constraints: [
      types: [
        email: [type: :string, constraints: [match: ~r/@/]],
        phone: [type: :string, constraints: [match: ~r/^+$/]]
      ]
    ]
end

Performance Considerations

  • Tagged unions are more efficient as they avoid trial-and-error casting
  • Type order matters in untagged unions - put more specific types first
  • Use constraints on member types to fail fast on invalid data

Constraints

  • :storage - How the value will be stored when persisted.
    :type_and_value will store the type and value in a map like so {type: :type_name, value: the_value} :map_with_tag will store the value directly. This only works if all types have a tag and tag_value configured. Valid values are :type_and_value, :map_with_tag The default value is :type_and_value.

  • :include_source? (boolean/0) - Whether to include the source changeset in the context. Defaults to the value of config :ash, :include_embedded_source_by_default, or true. In 4.x, the default will be false. The default value is true.

  • :types - The types to be unioned, a map of an identifier for the enum value to its configuration.
    When using tag and tag_value we are referring to a map key that must equal a certain value in order for the value to be considered an instance of that type.
    For example:

    types:  [
      int: [
        type: :integer,
        constraints: [
          max: 10
        ]
      ],
      object: [
        type: MyObjectType,
        # The default value is `true`
        # this passes the tag key/value to the nested type
        # when casting input
        cast_tag?: true,
        tag: :type,
        tag_value: "my_object"
      ],
      other_object: [
        type: MyOtherObjectType,
        cast_tag?: true,
        tag: :type,
        tag_value: "my_other_object"
      ],
      other_object_without_type: [
        type: MyOtherObjectTypeWithoutType,
        cast_tag?: false,
        tag: :type,
        tag_value: nil
      ]
    ]  

    IMPORTANT:
    This is stored as a map under the hood. Filters over the data will need to take this into account.
    Additionally, if you are not using a tag, a value will be considered to be of the given type if it successfully casts. This means that, for example, if you try to cast "10" as a union of a string and an integer, it will end up as "10" because it is a string. If you put the integer type ahead of the string type, it will cast first and 10 will be the value.

Summary

Functions

handle_change?()

prepare_change?()