Using Diffo.Type
Mix.install(
[
{:diffo, "~> 0.8.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.NewType -
Diffo.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.Dynamic
Diffo.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)
end
Because 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
end
Creating 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, [])
result
Unwrapping
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, [])
:ok
Checking 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