Hi everyone ![]()
I’m trying to improve the typespecs in my application contexts, but I’m running into dialyzer errors when dealing with schemas that have fields that can be nil.
Here’s an example:
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
email: String.t(),
age: non_neg_integer()
}
schema "users" do
field :email, :string
field :age, :integer
end
def changeset(user, attrs) do
user |> cast(attrs, [:email, :age]) |> validate_required(:email)
end
end
defmodule MyApp.UsersContext do
alias MyApp.User
@spec change_user(user :: User.t(), attrs :: map()) :: Ecto.Changeset.t()
def change_user(%User{} = user, attrs \\ %{}) do
User.changeset(user, attrs)
end
end
defmodule MyApp.FakeController do
alias MyApp.User
def index() do
MyApp.UsersContext.change_user(%User{}, %{})
:ok
end
end
In this schema, there are no default values so %User{} will generate a struct where every field is nil.
Using this setup, dialyzer complains with:
The function call will not succeed.
MyApp.UsersContext.change_user(
%MyApp.User{
:__meta__ => %Ecto.Schema.Metadata{
:context => nil,
:prefix => nil,
:schema => MyApp.User,
:source => <<117, 115, 101, 114, 115>>,
:state => :built
},
:age => nil,
:email => nil,
:id => nil
},
%{}
)
breaks the contract
(user :: MyApp.User.t(), attrs :: map()) :: Ecto.Changeset.t()
To fix this error I have to explicitly put | nil in MyApp.User.t() type:
@type t :: %__MODULE__{
id: integer() | nil,
email: String.t() | nil,
age: non_neg_integer() | nil
}
But here are some things I’m unsure about:
- Is it idiomatic to be so explicit with
| nilfor every nullable field? - Is there a better or preferred way to declare the schema type (
@type t) that keeps it maintainable and readable, especially in large schemas?
Another possible solution I’ve found is to declare, somewhere in the codebase, a generic schema typespec:
@type schema_t(schema) :: %{
optional(atom) => any,
__struct__: schema,
__meta__: Ecto.Schema.Metadata.t(schema)
}
and use it in my specs:
@spec change_user(user :: schema_t(User), attrs :: map()) :: Ecto.Changeset.t()
Curious how others approach this.
Thanks!






















