Mix.install(
[
{:diffo, "~> 0.3.0"}
],
consolidate_protocols: false
)Overview
Diffo.Type provides three complementary types for carrying values on Diffo resources:
Diffo.Type.Primitive— a discriminated union of the standard TMF primitive types (string, integer, float, boolean, date, time, datetime, duration)Diffo.Type.Dynamic— a runtime-typed wrapper for anyAsh.TypedStructor map-storageAsh.Type.NewTypeDiffo.Type.Value— a union of Primitive or Dynamic; the attribute type used byDiffo.Provider.Characteristic.value
All three types can also be used as array element types — {:array, Diffo.Type.Value}, {:array, Diffo.Type.Primitive}, or {:array, Diffo.Type.Dynamic} — when an attribute needs to hold multiple values.
These types do not require a Neo4j connection. Everything in this livebook runs in pure Elixir.
alias Diffo.Type.Value
alias Diffo.Type.Primitive
alias Diffo.Type.DynamicDiffo.Unwrap protocol
Diffo.Unwrap is a protocol that extracts the underlying Elixir value from Diffo and Ash wrapper types. It is defined with @fallback_to_any true, so any value that does not have an explicit implementation is returned as-is.
The protocol is recursive: each implementation calls Diffo.Unwrap.unwrap/1 on its inner value, so nested wrappers are peeled all the way down to the plain Elixir value in one call.
Built-in implementations:
| Type | Behaviour |
|---|---|
Ash.Union | delegates to inner :value |
Diffo.Type.Primitive | returns the primitive value |
Diffo.Type.Dynamic | delegates to inner :value |
Ash.CiString | returns the comparable string |
Ash.NotLoaded | raises — field was not loaded |
List | unwraps each element |
Any | returns the value unchanged |
Implementing Diffo.Unwrap on your own types
If your domain defines a struct that wraps a value, implement the protocol to make it transparent to Diffo:
defmodule MyApp.Tagged do
defstruct [:tag, :value]
end
defimpl Diffo.Unwrap, for: MyApp.Tagged do
def unwrap(%{value: value}), do: Diffo.Unwrap.unwrap(value)
endBecause the implementation calls Diffo.Unwrap.unwrap/1 on the inner value, nesting works automatically — a MyApp.Tagged wrapping a Diffo.Type.Primitive unwraps all the way to the raw Elixir value:
tagged = %MyApp.Tagged{tag: "example", value: Primitive.wrap("integer", 7)}
Diffo.Unwrap.unwrap(tagged)Arrays
All three types work as array element types. Diffo.Unwrap handles lists by unwrapping each element, so a stored list of wrapped values reduces to a plain Elixir list in one call.
primitives = [
Primitive.wrap("integer", 1),
Primitive.wrap("integer", 2),
Primitive.wrap("integer", 3)
]
Diffo.Unwrap.unwrap(primitives)The same applies to Value — after a cast roundtrip, unwrapping the list gives back the raw values:
values = [Value.primitive("string", "a"), Value.primitive("string", "b")]
cast_values =
Enum.map(values, fn v ->
{:ok, cast} = Ash.Type.cast_input(Value, v, Value.subtype_constraints())
cast
end)
Diffo.Unwrap.unwrap(cast_values)Primitive
Diffo.Type.Primitive wraps a single primitive value. Use wrap/2 to construct one from a type name and a value. Use Diffo.Unwrap.unwrap/1 to extract the value.
Primitive.wrap("string", "connectivity") |> Diffo.Unwrap.unwrap()Primitive.wrap("integer", 42) |> Diffo.Unwrap.unwrap()Primitive.wrap("float", 1.5) |> Diffo.Unwrap.unwrap()Primitive.wrap("boolean", false) |> Diffo.Unwrap.unwrap()Temporal types
Date, time, datetime, and duration values are converted to ISO 8601 strings internally. This avoids nested serialisation issues when storing through AshNeo4j.
Primitive.wrap("date", ~D[2026-04-24]) |> Diffo.Unwrap.unwrap()Primitive.wrap("time", ~T[09:30:00]) |> Diffo.Unwrap.unwrap()Primitive.wrap("datetime", ~U[2026-04-24 09:30:00Z]) |> Diffo.Unwrap.unwrap()Unknown types
wrap/2 returns nil for unrecognised type names.
Primitive.wrap("unknown", "x")Cast and dump roundtrip
The Primitive type integrates with the Ash type system.
value = Primitive.wrap("string", "connectivity")
{:ok, cast} = Ash.Type.cast_input(Primitive, value, Primitive.subtype_constraints())
{:ok, dumped} = Ash.Type.dump_to_native(Primitive, cast, Primitive.subtype_constraints())
{:ok, result} = Ash.Type.cast_stored(Primitive, dumped, Primitive.subtype_constraints())
Diffo.Unwrap.unwrap(result)Value
Diffo.Type.Value is the union type used for Diffo.Provider.Characteristic.value. Use Value.primitive/2 and Value.dynamic/1 to construct values. Stored values are %Ash.Union{} structs — use Diffo.Unwrap.unwrap/1 to extract the underlying Elixir value.
Value.primitive("string", "connectivity") |> Diffo.Unwrap.unwrap()Value.primitive("integer", 42) |> Diffo.Unwrap.unwrap()Nil values
Setting a characteristic value to nil is fully supported. The handle_change/3 override ensures Ash does not wrap nil in the previous member type.
Ash.Type.handle_change(Value, nil, nil, Value.subtype_constraints())old = %Ash.Union{type: :string, value: Primitive.wrap("string", "old")}
Ash.Type.handle_change(Value, old, nil, Value.subtype_constraints())Full roundtrip for a primitive Value
value = Value.primitive("float", 3.14)
{:ok, cast} = Ash.Type.cast_input(Value, value, Value.subtype_constraints())
{:ok, dumped} = Ash.Type.dump_to_native(Value, cast, Value.subtype_constraints())
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
Diffo.Unwrap.unwrap(result)Dynamic
Diffo.Type.Dynamic carries a value whose type is known only at runtime. The :type field is the Ash.Type.NewType module; :value is the cast value.
Dynamic is limited to types with storage_type: :map — Ash.TypedStruct and Ash.Type.NewType subtypes of :struct, :map, :union, :keyword, or :tuple. Scalar Ash types such as Ash.Type.Date are not supported.
Defining a typed struct
First, define a struct that will be the dynamic value. In a real application this is defined in your own domain — it does not need to be in Diffo itself.
defmodule MyApp.Patch do
use Ash.TypedStruct
typed_struct do
field :a_end, :integer, constraints: [min: 0]
field :z_end, :integer, constraints: [min: 0]
end
endCreating a Dynamic value
dynamic = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: 1, z_end: 42}}Cast roundtrip
{:ok, cast} = Ash.Type.cast_input(Dynamic, dynamic, [])
{:ok, dumped} = Ash.Type.dump_to_native(Dynamic, cast, [])
{:ok, result} = Ash.Type.cast_stored(Dynamic, dumped, [])
resultUnwrapping
Diffo.Unwrap.unwrap(result)Using Dynamic inside Value
Wrap the dynamic value using Value.dynamic/1, then round-trip through the Value union.
value = Value.dynamic(%MyApp.Patch{a_end: 1, z_end: 42})
{:ok, cast} = Ash.Type.cast_input(Value, value, Value.subtype_constraints())
{:ok, dumped} = Ash.Type.dump_to_native(Value, cast, Value.subtype_constraints())
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
Diffo.Unwrap.unwrap(result)Nil handling
{:ok, nil} = Ash.Type.cast_input(Dynamic, nil, [])
{:ok, nil} = Ash.Type.dump_to_native(Dynamic, nil, [])
{:ok, nil} = Ash.Type.cast_stored(Dynamic, nil, [])
:okChecking type compatibility
Dynamic.is_valid?/1 lets you check whether a module is usable as a Dynamic type before constructing a value. It returns true only for Ash.Type.NewType modules with storage_type: :map:
Dynamic.is_valid?(MyApp.Patch)Dynamic.is_valid?(Ash.Type.Date)Dynamic.is_valid?(NonExistent.Module)Constraint validation
Dynamic enforces the constraints defined on the inner type during casting. Here MyApp.Patch requires both fields to be >= 0, so passing a negative value returns an error:
invalid = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: -1, z_end: 42}}
Ash.Type.cast_input(Dynamic, invalid, [])A valid value casts successfully:
valid = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: 0, z_end: 42}}
Ash.Type.cast_input(Dynamic, valid, [])Further reading
- Diffo Livebook — full tutorial including Neo4j setup and Provider resources
- Using Diffo Provider Instance Extension — defining custom resources with typed characteristics