Structify

Structify is an Elixir library that provides powerful functionality to coerce between maps, structs, and lists recursively.

Hex.pmDocumentation

Installation

Add structify to your list of dependencies in mix.exs:

def deps do
  [
    {:structify, "~> 0.1.0"}
  ]
end

Documentation

The docs can be found at https://hexdocs.pm/structify.

Features

Conversion and cleanup approaches

Structify.Coerce - Lossy conversions with simple return values:

Structify.Convert - Lossless conversions with explicit result tuples:

Structify.Destruct - Deep cleanup and meta removal:

Core Capabilities

Quick Start

# Define your structs
defmodule User do
  defstruct [:name, :email, :age]
end

defmodule Company do
  defstruct [:name, :users, :address]
end

defmodule Address do
  defstruct [:street, :city, :country]
end

# Basic conversions
iex> input = %{name: "Alice", email: "alice@example.com", age: 30}
iex> Structify.coerce(input, User)
%User{name: "Alice", email: "alice@example.com", age: 30}

iex> input = %{name: "Alice", email: "alice@example.com", age: 30}
iex> Structify.convert(input, User)
{:ok, %User{name: "Alice", email: "alice@example.com", age: 30}}

String Key Coercion

Structify automatically handles string keys when converting to structs:

# String keys are converted to atoms when targeting structs
iex> input = %{"name" => "Alice", "email" => "alice@example.com", "age" => 30}
iex> Structify.coerce(input, User)
%User{name: "Alice", email: "alice@example.com", age: 30}

# String keys are preserved when targeting maps
iex> input = %{"name" => "Alice", "email" => "alice@example.com", "age" => 30}
iex> Structify.coerce(input, nil)
%{"name" => "Alice", "email" => "alice@example.com", "age" => 30}

# Mixed key types work gracefully
iex> input = %{:name => "Alice", "email" => "alice@example.com", "age" => 30}
iex> Structify.coerce(input, User)
%User{name: "Alice", email: "alice@example.com", age: 30}

Nested Transformations

Full Syntax with :__to__ Key

input = %{
  name: "TechCorp",
  users: [
    %{name: "Alice", email: "alice@example.com", age: 30},
    %{name: "Bob", email: "bob@example.com", age: 25}
  ],
  address: %{street: "123 Main St", city: "Anytown", country: "USA"}
}

nested = [
  users: [__to__: User],           # Convert each user map to User struct
  address: [__to__: Address]       # Convert address map to Address struct
]

Structify.coerce(input, Company, nested)
# => %Company{
#      name: "TechCorp",
#      users: [%User{...}, %User{...}],
#      address: %Address{street: "123 Main St", city: "Anytown", country: "USA"}
#    }

Module Shorthand Syntax

# Shorthand syntax - much cleaner!
nested = [
  users: User,                     # Equivalent to [__to__: User]
  address: Address                 # Equivalent to [__to__: Address]
]

Structify.coerce(input, Company, nested)
# Same result as above

Deep Nesting

input = %{
  companies: [
    %{
      name: "TechCorp",
      ceo: %{name: "CEO Alice", email: "ceo@techcorp.com", age: 45},
      address: %{street: "456 Business Ave", city: "Metropolis", country: "USA"}
    }
  ]
}

nested = [
  companies: [
    ceo: User,                     # Shorthand for CEO conversion
    address: Address               # Shorthand for address conversion
  ]
]

result = Structify.coerce(input, nil, nested)
# => %{
#      companies: [
#        %{
#          name: "TechCorp",
#          ceo: %User{name: "CEO Alice", email: "ceo@techcorp.com", age: 45},
#          address: %Address{street: "456 Business Ave", city: "Metropolis", country: "USA"}
#        }
#      ]
#    }

Error Handling with Convert

# Convert provides detailed error information
iex> Structify.convert(%{invalid: "data"}, NonExistentModule)
{:error, {NonExistentModule, :not_struct}}

# No-change optimization
iex> user = %User{name: "Alice", email: "alice@example.com", age: 30}
iex> Structify.convert(user, User)  # Same type, no nested rules
{:no_change, %User{name: "Alice", email: "alice@example.com", age: 30}}

# Use convert! to raise on error
iex> Structify.convert!(%{name: "Alice"}, User)
%User{name: "Alice", email: nil, age: nil}

iex> Structify.convert!(%{invalid: "data"}, NonExistentModule)
** (ArgumentError) NonExistentModule is not a struct

Configuration Options

The :__to__ Key - Four Use Cases

  1. Convert to struct: nested = [field: [__to__: MyStruct]]
  2. Convert to map: nested = [field: [__to__: nil]]
  3. Pass-through with nested rules: nested = [field: [nested_field: [__to__: MyStruct]]]
  4. Module shorthand: nested = [field: MyStruct] (equivalent to case 1)

Mixed Syntax

nested = [
  user: User,                      # Shorthand
  address: [__to__: Address],      # Full syntax
  metadata: [                      # Pass-through with nested rules
    created_by: User,
    updated_by: User
  ]
]

List Processing

# Lists are processed element-wise, with nil filtering
input = [
  %{name: "Alice", age: 30},
  nil,                             # This will be filtered out
  %{name: "Bob", age: 25}
]

Structify.coerce(input, User)
# => [%User{name: "Alice", age: 30}, %User{name: "Bob", age: 25}]

Well-known Types

Date, Time, NaiveDateTime, and DateTime structs are preserved unchanged:

iex> date = ~D[2023-09-18]
iex> Structify.coerce(date, User)
~D[2023-09-18]  # Returned unchanged

iex> date = ~D[2023-09-18]
iex> Structify.convert(date, nil)
{:no_change, ~D[2023-09-18]}

Destruct (deep cleanup)

Structify.destruct/1 deeply cleans data structures by removing internal/meta keys and recursively processing lists, maps, and structs while preserving common date/time structs.

Behavior and rules

This is useful when you want to prepare data for JSON serialization or for returning plain maps from code that may produce structs with attached metadata.

Examples

iex> Structify.destruct(Map.put(%User{name: "Alice", email: "alice@example.com"}, :__meta__, :foo))
%{name: "Alice", email: "alice@example.com", age: nil}

iex> Structify.destruct([%User{name: "Alice"}, nil, 1])
[%{name: "Alice", email: nil, age: nil}, 1]

iex> Structify.destruct(%{"foo" => 1, :bar => 2, __meta__: :skip})
%{"foo" => 1, :bar => 2}

iex> Structify.destruct(~D[2020-01-01])
~D[2020-01-01]

iex> Structify.destruct(%{user: Map.put(%User{name: "Alice"}, :__meta__, :foo)})
%{user: %{name: "Alice", email: nil, age: nil}}

iex> Structify.destruct(nil)
nil

API

@spec destruct(term()) :: term()

Removes meta keys and returns a deeply cleaned structure suitable for serialization or for consumers that expect plain maps/lists rather than structs with metadata.

API Reference

Structify.coerce/3

@spec coerce(term(), module() | nil, keyword() | map()) :: term()

Performs lossy conversion, returning the result directly.

Structify.convert/3

@spec convert(term(), module() | nil, keyword() | map()) ::
  {:ok, term()} | {:error, {Exception.t(), module()}} | {:no_change, term()}

Performs lossless conversion with explicit result tuples.

Structify.convert!/3

@spec convert!(term(), module() | nil, keyword() | map()) :: term()

Like convert/3 but raises the appropriate exception on error, returns result directly on success.