Free & open source — no account required

v1.2.5-PRICING-19
Web & Frontend • Engineering Documentation

JSON to Elixir Struct Generator

This technical guide provides an in-depth analysis of the json to elixir struct engine, best practices for implementation, and data security standards.

JSON to Elixir Struct: Pattern Matching, Jason, and Ecto Schemas

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.

Live Example: User Struct with Jason Decoding

# 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"

Pattern Matching for JSON Processing

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

with Chains for JSON Validation Pipelines

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

Ecto Schema: Database-Backed Structs with Changesets

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

Struct vs Ecto Schema: When to Use Each

## 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

Jason Encoder/Decoder Customization

## 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)

Best Practices for Production

  • Use @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.
  • Always pattern match on {: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.
  • Prefer string keys in JSON maps: Using 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.
  • Use Ecto embedded schemas for nested JSON: For structured sub-objects stored in a JSON/JSONB column, embeds_one / embeds_many gives you changeset validation, type coercion, and nested error messages on the parent's changeset.

FAQ

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.

Developer FAQ

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.