Phoenix.Component.to_form/1 seems to be confused by boolean field defaults

I have a schema with a boolean field with a default value of true. I’m using this in a LiveView, wrapped in a changeset and converted to a Phoenix.HTML.Form with to_form/1. As well as rendering an input for the field, there’s another part of the form that displays conditionally depending on whether the boolean field is set (<div :if={not @form[my_field].value ...>}. This works on page load, and when the field is toggled to false, but when I toggle it back to true the view crashes with an argument error, because it turns out the value is now set to the string "true" instead of the boolean true.

I can work around this with :if={@form[:my_field].value in [false, "false"], but I wondered whether this is expected behaviour or a bug.

I’ve reduced it to a simple demo in a LiveBook with just a struct and changeset, which you can find in this gist. Here are the key bits:

defmodule Person do
  alias Ecto.Changeset

  defstruct name: "", member: true

  def changeset(person, changes \\ %{}) do
    Changeset.cast(
      {person,
       %{name: :string, member: :boolean}},
      changes,
      [:name, :member]
    )
  end
end

The changeset itself handles the casting correctly:

changeset = %Person{} |> Person.changeset()
IO.inspect(Changeset.fetch_field!(changeset, :member), label: "Default")

changeset = %Person{} |> Person.changeset(%{"member" => "false"})
IO.inspect(Changeset.fetch_field!(changeset, :member), label: "From default to false")

changeset = %Person{} |> Person.changeset(%{"member" => "true"})
IO.inspect(Changeset.fetch_field!(changeset, :member), label: "From default to true")

changeset = %Person{member: true} |> Person.changeset(%{"member" => "false"})
IO.inspect(Changeset.fetch_field!(changeset, :member), label: "From true to false")

changeset = %Person{member: false} |> Person.changeset(%{"member" => "true"})
IO.inspect(Changeset.fetch_field!(changeset, :member), label: "From false to true")
Default: true
From default to false: false
From default to true: true
From true to false: false
From false to true: true

But when I turn it into a form, it treats the toggle back to true as a change to the string "true":

form = %Person{} |> Person.changeset() |> to_form()
IO.inspect(form[:member].value, label: "Default")

form = %Person{} |> Person.changeset(%{"member" => "false"}) |> to_form()
IO.inspect(form[:member].value, label: "From default to false")

form = %Person{} |> Person.changeset(%{"member" => "true"}) |> to_form()
IO.inspect(form[:member].value, label: "From default to true")

form = %Person{member: true} |> Person.changeset(%{"member" => "false"}) |> to_form()
IO.inspect(form[:member].value, label: "From true to false")

form = %Person{member: false} |> Person.changeset(%{"member" => "true"}) |> to_form()
IO.inspect(form[:member].value, label: "From false to true")
Default: true
From default to false: false
From default to true: "true"
From true to false: false
From false to true: true

If it turns out to be a bug I’ll raise an issue, but it’s much more likely I’m just doing something wrong!

This is expected behaviour. You want to be able to handle the string format, because the fallback in case a value wasn’t able to be cast (when using changeset) is to use the string value as supplied by the submit. E.g. consider a date input, where the user was in the middle of a date. You don’t want to clear out their input value just because it’s not yet a valid date.

Thanks for the clarification @LostKobrakai