Mix.install(
  [
    {:diffo, "~> 0.3.0"}
  ],
  consolidate_protocols: false
)

Overview

Diffo.Type provides three complementary types for carrying values on Diffo resources:

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:

TypeBehaviour
Ash.Uniondelegates to inner :value
Diffo.Type.Primitivereturns the primitive value
Diffo.Type.Dynamicdelegates to inner :value
Ash.CiStringreturns the comparable string
Ash.NotLoadedraises — field was not loaded
Listunwraps each element
Anyreturns 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: :mapAsh.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