Automatic enum values conversion in Elixir

3 months ago

One annoying detail about web forms is that on submission (unless you are using changesets) all the values come back as strings. This forces you to have to handle the correct formats before, say, passing those params into a service module to execute some action.

To simplify this process, i recently created a simple guesser function that evaluates the values of a map, namely booleans, integers, floats, dates, and datetimes. It is recursive and can go into lists as well. Check the examples:

  @doc """
  Function to try to guess the type of a variable and return the guessed value. Some
  applications might include converting a parameter map coming from a form (all strings)
  into a proper map with typed values, for instance:
       %{foo: "123", bar: "true", foobar: "2020-12-13"}
  would become:
       %{foo: 123, bar: true, foobar: ~D[2020-12-13]}
  The function supports simple values (a string, for instance), is also recursive into
  lists and maps and, if it can't figure out the type, it returns the original value.
  Will also trim the values before testing so `" 123"`, for instance, becomes `123`.
  ## Examples
  ## Simple types
      iex> to_typed("")
      ""
      iex> to_typed("123")
      123
      iex> to_typed(" 456")
      456
      iex> to_typed("-123")
      -123
      iex> to_typed("123.345")
      123.345
      iex> to_typed("-12.3")
      -12.3
      iex> to_typed("true")
      true
      iex> to_typed("  true  ")
      true
      iex> to_typed("false")
      false
      iex> to_typed("2020-12-13")
      ~D[2020-12-13]
      iex> to_typed("2015-01-23T23:50:07Z")
      ~U[2015-01-23 23:50:07Z]
      iex> to_typed("2015-01-23T23:50:07.123+02:30")
      ~U[2015-01-23 21:20:07.123Z]
  ## Maps
      iex> to_typed(%{})
      %{}
      iex> to_typed(%{another_integer: "5345345435"})
      %{another_integer: 5345345435}
      iex> to_typed(%{another_map: %{foo: "true"}})
      %{another_map: %{foo: true}}
      iex> to_typed(%{a_list: ["foo", "true"]})
      %{a_list: ["foo", true]}
  ## Lists
      iex> to_typed([])
      []
      iex> to_typed(["123"])
      [123]
      iex> to_typed(["12.3"])
      [12.3]
      iex> to_typed(["true", "false"])
      [true, false]
      iex> to_typed(["true", ["false"]])
      [true, [false]]
      iex> to_typed([%{foo: "1"}, %{foo: "2.5"}])
      [%{foo: 1}, %{foo: 2.5}]
      iex> to_typed(["bla", "bla", "2015-01-23T23:50:07Z", ["12345", %{cool: "true"}]])
      ["bla", "bla", ~U[2015-01-23 23:50:07Z], [12345, %{cool: true}]]
  ## Other
      iex> to_typed(nil)
      nil
      iex> to_typed(123)
      123
      iex> to_typed("actually_a_string")
      "actually_a_string"
  """
  @spec to_typed(any()) :: any()
  def to_typed(map) when is_map(map) do
    map
    |> Enum.map(fn {key, value} ->
      value =
        cond do
          is_map(value) -> to_typed(value)
          is_list(value) -> to_typed(value)
          true -> to_typed(value)
        end

      {key, value}
    end)
    |> Map.new()
  end

  def to_typed(list) when is_list(list) do
    Enum.map(list, fn value ->
      cond do
        is_map(value) or is_list(value) -> to_typed(value)
        is_binary(value) -> to_typed(value)
        true -> value
      end
    end)
  end

  def to_typed(value) when is_binary(value) do
    value = String.trim(value)

    cond do
      value == "true" ->
        true

      value == "false" ->
        false

      value =~ ~r/\A\d{4}-\d{2}-\d{2}\z/ ->
        value |> Timex.parse!("{ISOdate}") |> Timex.to_date()

      value =~ ~r/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ ->
        value |> Timex.parse!("{ISO:Extended}") |> Timex.to_datetime()

      value =~ ~r/\A-?\d+\.\d+\z/ ->
        String.to_float(value)

      value =~ ~r/\A-?\d+\z/ ->
        String.to_integer(value)

      true ->
        value
    end
  end

  def to_typed(value), do: value

Hope you find it useful.

Happy Elixir'ing...