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.
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.
#
# 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 structElixir'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
defstructarguments before passing them to originaldefstruct(to remove defaults or keep them as-is) - keeping call to the original
defstructinstead 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.
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 evaluationRaises 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)Controls factory function name:
:new(default) - Createsnew/0andnew/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}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.
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.
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.
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__/0behavior
Share your approach in the issues! Feature requests are also welcome!
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
endThe module attribute makes the distinction explicit.
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
endThen use MyApp.DynamicDefaults everywhere instead of use DynamicDefaults, ....
Started as a proposal for Elixir core. The feedback shaped this package.
# mix.exs
def deps do
[
{:dynamic_defaults, "~> 0.0.1"}
]
endIf using forbid_literal_syntax: true, add to mix.exs:
def project do
[
# ... other config ...
elixirc_options: [tracers: [DynamicDefaults.Tracer]]
]
end