Free & open source — no account required
Free & open source — no account required
This technical guide provides an in-depth analysis of the json to elixir struct engine, best practices for implementation, and data security standards.
Elixir structs are maps with a fixed set of keys and optional default values, enforced at compile time. Converting JSON to Elixir structs requires understanding three layers: the defstruct definition and its defaults, Jason's @derive macro for encoding/decoding, and the distinction between structs (for in-memory data passing) and Ecto schemas (for database-backed data with changesets and validation). Elixir's pattern matching makes JSON processing particularly expressive — you can destructure, validate, and transform in a single with chain.
# Input JSON
{
"user_id": "usr_001",
"username": "alice_dev",
"email": "alice@example.com",
"role": "admin",
"is_active": true,
"metadata": { "timezone": "UTC", "locale": "en-US" }
}
# Generated Elixir Struct
defmodule MyApp.User do
@derive {Jason.Encoder, only: [:user_id, :username, :email, :role, :is_active]}
@enforce_keys [:user_id, :username, :email] # required on struct creation
defstruct [
:user_id,
:username,
:email,
role: "viewer", # default value
is_active: true,
metadata: %{}
]
@type t :: %__MODULE__{
user_id: String.t(),
username: String.t(),
email: String.t(),
role: String.t(),
is_active: boolean(),
metadata: map()
}
end
# Decoding JSON → Struct
defmodule MyApp.User do
def from_json(json_string) when is_binary(json_string) do
with {:ok, map} <- Jason.decode(json_string) do
from_map(map)
end
end
def from_map(%{} = map) do
{:ok, %__MODULE__{
user_id: Map.fetch!(map, "user_id"),
username: Map.fetch!(map, "username"),
email: Map.fetch!(map, "email"),
role: Map.get(map, "role", "viewer"),
is_active: Map.get(map, "is_active", true),
metadata: Map.get(map, "metadata", %{})
}}
rescue
KeyError -> {:error, :missing_required_field}
end
end
# Usage
{:ok, user} = MyApp.User.from_json(json_payload)
user.username # "alice_dev"
user.role # "admin"
Elixir's pattern matching enables declarative JSON transformation that would require conditional logic in other languages:
defmodule MyApp.EventParser do
# Pattern match on the event type key
def parse(%{"type" => "user_created", "payload" => payload}) do
{:user_event, :created, parse_user(payload)}
end
def parse(%{"type" => "order_placed", "payload" => payload}) do
{:order_event, :placed, parse_order(payload)}
end
def parse(%{"type" => type}) do
{:error, {:unknown_type, type}}
end
def parse(_) do
{:error, :invalid_shape}
end
# Process a list of events from JSON array
def parse_many(json_list) when is_list(json_list) do
json_list
|> Enum.map(&parse/1)
|> Enum.split_with(fn {status, _} -> status != :error end)
# → {successes, failures}
end
defp parse_user(%{"id" => id, "email" => email} = data) do
%MyApp.User{
user_id: id,
username: Map.get(data, "username", ""),
email: email
}
end
end
def process_api_request(body) do
with {:ok, json} <- Jason.decode(body),
{:ok, params} <- validate_required(json, ["user_id", "amount"]),
{:ok, amount} <- parse_decimal(params["amount"]),
{:ok, user} <- MyApp.Accounts.fetch_user(params["user_id"]),
:ok <- authorize(user, :make_payment) do
MyApp.Payments.charge(user, amount)
else
{:error, %Jason.DecodeError{}} ->
{:error, :invalid_json}
{:error, {:missing_fields, fields}} ->
{:error, {:validation, "Missing: #{Enum.join(fields, ", ")}"}}
{:error, :user_not_found} ->
{:error, :not_found}
{:error, :unauthorized} ->
{:error, :forbidden}
end
end
defp validate_required(map, keys) do
missing = Enum.reject(keys, &Map.has_key?(map, &1))
if missing == [], do: {:ok, map}, else: {:error, {:missing_fields, missing}}
end
defp parse_decimal(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, ""} -> {:ok, decimal}
_ -> {:error, :invalid_decimal}
end
end
defmodule MyApp.Schema.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :user_id, :string
field :username, :string
field :email, :string
field :role, :string, default: "viewer"
field :is_active, :boolean, default: true
field :metadata, :map, default: %{}
timestamps() # inserts inserted_at, updated_at
end
@required_fields [:user_id, :username, :email]
@optional_fields [:role, :is_active, :metadata]
@valid_roles ~w[admin editor viewer]
def changeset(user, attrs) do
user
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|> validate_inclusion(:role, @valid_roles)
|> validate_length(:username, min: 3, max: 50)
|> unique_constraint(:user_id)
|> unique_constraint(:email)
end
end
# Insert from JSON
defmodule MyApp.Accounts do
alias MyApp.Repo
alias MyApp.Schema.User
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
# Returns {:ok, user} or {:error, changeset}
# changeset.errors contains field-level error messages
end
# Usage in Phoenix controller
def create(conn, params) do
case MyApp.Accounts.create_user(params) do
{:ok, user} ->
conn |> put_status(:created) |> json(%{id: user.id})
{:error, changeset} ->
errors = Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
conn |> put_status(:unprocessable_entity) |> json(%{errors: errors})
end
end
## Plain defstruct — use when:
# - In-memory data transfer (no DB persistence)
# - Parsing external API responses
# - Function parameters that need type documentation
# - No validation rules needed (or validation done elsewhere)
defmodule MyApp.ApiResponse do
defstruct [:status, :data, :error, request_id: nil]
end
## Ecto Schema — use when:
# - Data is stored in a database table
# - You need changeset validation (multi-step form, API endpoint)
# - You need associations (has_many, belongs_to)
# - You need query composition (Ecto.Query)
## Embedded Ecto Schema — use for nested JSON objects without a table:
defmodule MyApp.Schema.Address do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :street, :string
field :city, :string
field :zip, :string
field :country, :string, default: "US"
end
def changeset(address, attrs) do
address
|> cast(attrs, [:street, :city, :zip, :country])
|> validate_required([:street, :city, :zip])
end
end
defmodule MyApp.Schema.User do
use Ecto.Schema
schema "users" do
field :name, :string
embeds_one :address, MyApp.Schema.Address, on_replace: :update
end
end
## Custom JSON encoding for a struct
defmodule MyApp.Money do
defstruct [:amount, :currency]
defimpl Jason.Encoder do
def encode(%{amount: amount, currency: currency}, opts) do
Jason.Encode.map(%{
"amount" => Decimal.to_string(amount),
"currency" => currency
}, opts)
end
end
end
## Custom decoder: Jason.decode with atoms (use carefully — atoms aren't GC'd)
Jason.decode(json, keys: :atoms)
# → %{user_id: "usr_001", username: "alice"} (atom keys)
## Preferred: keys as strings (default), pattern match on strings
Jason.decode(json)
# → %{"user_id" => "usr_001", "username" => "alice"} (string keys)
@enforce_keys for required struct fields: Without @enforce_keys, %User{} silently sets missing fields to nil. With it, creating the struct without required fields raises a compile-time error, catching mistakes before runtime.{:ok, value} and {:error, reason}: Elixir conventions use tagged tuples. Use with chains to compose multiple operations that may fail, keeping the happy path flat and errors explicit at the end.Jason.decode(json, keys: :atoms) is convenient but dangerous — atoms are not garbage collected, and an attacker could exhaust memory by sending arbitrary JSON keys. Always decode with string keys (the default) and access known keys explicitly.embeds_one / embeds_many gives you changeset validation, type coercion, and nested error messages on the parent's changeset.Q: What's the difference between a struct and a map in Elixir?
A: A struct is a map with a fixed set of keys (defined at compile time), an enforced module name as a key (__struct__), and optional default values. Pattern matching on a struct (%User{} = value) ensures the value is that specific struct type, not any map. Maps are flexible and untyped.
Q: How do I handle large JSON arrays efficiently?
A: Use Jason.decode_stream/2 (or :ijson for event-driven parsing) for JSON streams too large to hold in memory. For processing large arrays from disk, the Stream module combined with line-by-line reading works for newline-delimited JSON (NDJSON).
Q: How does Ecto handle JSON/JSONB columns?
A: Use field :metadata, :map for a simple map, or define an embedded_schema for structured access with validation. On PostgreSQL with JSONB, Ecto's fragment lets you use @> (containment) and ->> (key access) operators in queries: where(fragment("metadata @> ?", ^%{key: value})).
Q: Should I use Jason or Poison for JSON in Elixir?
A: Jason — it's significantly faster (benchmarks show 3-10× vs Poison), actively maintained, and used by Phoenix and Ecto. Poison is largely legacy. The only reason to use Poison today is maintaining an older codebase that already depends on it.
Is the processing local-only?
Absolutely. TypeMorph operates entirely within your browser's sandbox. We use Web Workers for high-performance computation without ever transmitting your JSON, SQL, or API data to a remote server.
Can I use this for enterprise projects?
Yes. The tool is designed for professional software engineers who require GDPR compliance and data privacy. It is trusted by developers at top-tier startups and financial institutions.