Ash.Type.Union (ash v3.5.24)
View SourceA 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:
- Tagged unions - Uses a specific field (tag) and value (tag_value) to identify the type
- 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 atag
andtag_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 ofconfig :ash, :include_embedded_source_by_default
, ortrue
. In 4.x, the default will befalse
. The default value istrue
.:types
- The types to be unioned, a map of an identifier for the enum value to its configuration.
When usingtag
andtag_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 and10
will be the value.