Skip to content

ffloyd/dynamic_defaults

Repository files navigation

DynamicDefaults

Simplifies the dynamic defaults pattern in Elixir. Instead of manually writing factory functions that split defaults application across multiple places, define all defaults in defstruct and let DynamicDefaults handle the runtime re-evaluation.

Should you use this?

Consider using DynamicDefaults if you fit the following criteria:

  • Working on existing big codebase where you need to add dynamic defaults and want to minimize refactoring effort.
  • Want less boilerplate and better readability for dynamic defaults pattern.

On small or fresh projects using this library may be overkill, but read this README anyway to understand the problem this library solves and identify your way to prevent it in the future of your codebase.

Quick Start

#
# Vanilla: Manual factory function
#
defmodule User do
  defstruct name: "default name", created_at: nil

  def new(attrs \\ %{}) do
    struct!(__MODULE__, Map.merge(%{created_at: DateTime.utc_now()}, attrs))
  end
end

#
# Simplest DynamicDefaults usage, use it as a starting point
#
defmodule User do
  use DynamicDefaults

  defstruct name: "Bob", created_at: DateTime.utc_now()
end

User.new()  # Re-evaluates DateTime.utc_now() each call
%User{} # Evaluates all defaults at compile time (standard Elixir behavior)

#
# Advanced DynamicDefaults usage, option 1: defaults are applied ONLY via factory function
#
defmodule User do
  use DynamicDefaults, defstruct_behavior: :ignore_defaults

  defstruct name: "Bob", created_at: DateTime.utc_now()
end

User.new()  # Re-evaluates DateTime.utc_now() each call
%User{} == %User{name: nil, created_at: nil} # any other way of struct creation except factory do not apply defaults

#
# Advanced DynamicDefaults usage, option 2: defaults are always applied at runtime, but literal syntax is forbidden
#
defmodule User do
  use DynamicDefaults, defstruct_behavior: :override, forbid_literal_syntax: true

  defstruct name: "Bob", created_at: DateTime.utc_now()
end

User.new()  # Re-evaluates DateTime.utc_now() each call
struct!(User)  # Re-evaluates DateTime.utc_now() each call
%User{}  # Compile error: creation via literal struct syntax is forbidden for User struct

How it works

Elixir's defstruct evaluates defaults at compile time. DynamicDefaults wraps defstruct macro and it allows:

  • getting defstruct's arguments' AST and generating a factory function that applies defaults at runtime
  • modifying defstruct arguments before passing them to original defstruct (to remove defaults or keep them as-is)
  • keeping call to the original defstruct instead of reimplementing its behavior (which would increase maintenance burden and risk of bugs)

In order to implement forbid_literal_syntax, DynamicDefaults uses Elixir's compiler tracers to detect literal struct syntax usage.

Options

defstruct_behavior

Most important option. Controls how the original defstruct is used under the hood.

:keep (default) - standard behavior, %Point{} syntax, struct and struct! functions still use defaults as static compile-time values; only factory function applies defaults at runtime

defmodule Point do
  use DynamicDefaults  # defstruct_behavior: :keep is default

  defstruct x: System.os_time(), y: System.os_time()
end

iex> %Point{} == %Point{}
true  # Compile-time defaults (standard Elixir)

iex> Point.new() == Point.new()
false  # Runtime re-evaluation via factory

:ignore_defaults - %Point{} syntax, struct and struct! functions create structs with nil fields. Only factory function applies defaults as runtime-calculated values. It's similar to Golang's struct behavior, where defaults are only applied via constructor functions.

defmodule Point do
  use DynamicDefaults, defstruct_behavior: :ignore_defaults

  defstruct x: System.os_time(), y: System.os_time()
end

iex> %Point{}
%Point{x: nil, y: nil}  # No defaults via literal

iex> struct!(Point)
%Point{x: nil, y: nil}  # No defaults via struct!

iex> Point.new()
%Point{x: 1699876543210, y: 1699876543211}  # Defaults via factory

:override - makes struct and struct! recalculate defaults at runtime. Literal %Point{} gets compile-time snapshot. It's achieved by overriding undocumented __struct__/0 and __struct__/1 functions generated by defstruct. See Risks & Tradeoffs section for details.

defmodule Point do
  use DynamicDefaults, defstruct_behavior: :override

  defstruct x: System.os_time(), y: System.os_time()
end

defmodule Test do
  def literal_a, do: %Point{}
  def literal_b, do: %Point{}
end

iex> Test.literal_a() == Test.literal_a()
true  # Compile-time snapshot per literal

iex> Test.literal_a() == Test.literal_b()
false  # Each literal gets different compile-time values

iex> struct!(Point) == struct!(Point)
false  # Runtime evaluation

forbid_literal_syntax

Raises CompileError when literal syntax %Point{} is used. Forces struct creation via factory or struct/struct! functions.

Combined with defstruct_behavior: :override, guarantees runtime re-evaluation for all struct creation!

Uses Elixir's compiler tracers to detect literals even in macro expansions.

Requires tracer setup (see Installation section below).

defmodule Point do
  use DynamicDefaults, defstruct_behavior: :override, forbid_literal_syntax: true

  defstruct x: System.os_time(), y: System.os_time()
end

%Point{}
# Compile error: Literal struct syntax is forbidden for Point

# OK: These work fine
def create, do: Point.new()
def create, do: struct!(Point)
def create(args), do: struct!(Point, args)

factory_fn

Controls factory function name:

  • :new (default) - Creates new/0 and new/1
  • Any atom - Creates factory with that name (e.g., :create)
  • false - No factory function
defmodule Point do
  use DynamicDefaults, factory_fn: :create

  defstruct x: System.os_time(), y: System.os_time()
end

iex> Point.create()
%Point{x: 1699876543210, y: 1699876543211}

iex> Point.create(%{x: 100})
%Point{x: 100, y: 1699876543215}

⚠️ Risks & Tradeoffs

defstruct_behavior: :override

This changes fundamental Elixir behavior. It overrides __struct__/0 and __struct__/1 to recalculate defaults at runtime, making %Struct{}, struct! and struct behave differently than standard Elixir. Literal syntax %Point{} still uses compile-time defaults, but logic is different: one snapshot per literal location instead of one snapshot per compilation. While any other way of struct creation (via struct!(Point), etc) re-evaluates defaults on each call. This can be confusing.

Solution: Use forbid_literal_syntax: true to eliminate the inconsistency by forcing all struct creation through runtime-evaluated paths that look like function calls.

Undocumented API risk: While defstruct creates __struct__/0 and __struct__/1, they're not documented as public API. However, risk is low — too much Elixir code relies on these functions. Would likely only change with major struct redesign (which might make this library obsolete anyway).

Solution: If you're afraid of even this low risk, consider using :ignore_defaults.

forbid_literal_syntax

Risk: Third-party macros generating %YourStruct{} AST internally will cause compilation errors.

Solution: do a PR to those libraries to use struct!/2 instead of literal syntax in macro implementation.

Adoption Strategies

1. Conservative (Safest)

Use defstruct_behavior: :ignore_defaults.

You accept the following convention:

  • %Point{}, struct, struct! create "bare" without defaults applied
  • Factory is Single Source of Truth for defaults, and the only way to get them applied

Risk:

  • Some libraries may assume %Struct{} applies defaults.

2. Aggressive (Most Powerful)

defstruct_behavior: :override + forbid_literal_syntax: true - enforces runtime re-evaluation everywhere.

Remove all literal syntax from codebase.

You accept the following convention:

  • %Point{} syntax is forbidden (compile error)
  • any other way of struct creation (struct, struct!, factory) always applies defaults at runtime

Risks:

  • Third-party macros generating literals cause compilation errors
  • Relies on undocumented __struct__/0 behavior

3. Your Own!

Share your approach in the issues! Feature requests are also welcome!

Recipes

Mixing static and dynamic defaults

All defaults are dynamic from factory's perspective. To make some static, use module attributes:

defmodule Point do
  use DynamicDefaults

  @static_x System.os_time()  # Evaluated once at compile time

  defstruct x: @static_x,      # Static
            y: System.os_time() # Dynamic
end

The module attribute makes the distinction explicit.

Global configuration

Create a wrapper to set project-wide defaults:

defmodule MyApp.DynamicDefaults do
  defmacro __using__(_opts) do
    quote do
      use DynamicDefaults,
        defstruct_behavior: :override,
        forbid_literal_syntax: true
    end
  end
end

Then use MyApp.DynamicDefaults everywhere instead of use DynamicDefaults, ....

Related discussions

Started as a proposal for Elixir core. The feedback shaped this package.

Installation

# mix.exs
def deps do
  [
    {:dynamic_defaults, "~> 0.0.1"}
  ]
end

Tracer Setup (Required for forbid_literal_syntax)

If using forbid_literal_syntax: true, add to mix.exs:

def project do
  [
    # ... other config ...
    elixirc_options: [tracers: [DynamicDefaults.Tracer]]
  ]
end

About

Better support for dynamic defaults in Elixir structs.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published