Notes to self

Converting snake_case keys to camelCase in Elixir

Converting a snake_case map keys to camelCase is a pretty common task in the snake-case-style languages working with the JavaScript frontend. Here are the basics in understanding how you can convert maps to camelCase style in Elixir.

Imagine we have a similar map:

data = %{
  data: %{
    id: 1,
    type: "documents",
    attributes: %{
      author: %{
       first_name: "Jo",
       last_name: "Nesbo"
      }
    }
  }
}

And we want to endup with the JSON like (let’s ignore the actual JSON conversion ala Jason.encode):

{
  "data": {
    "id": "1",
    "type": "documents",
    "attributes": {
      "author": {
        "firstName": "Jo",
        "lastName": "Nesbo"
      }
    }
  }
}

There are basically three things we need to take into account:

  1. converting anything (string) to camelCase
  2. converting an Elixir map keys to camelCase
  3. finally deeply converting an Elixir map to camelCase

How do we do that?

For converting a simple string on its own we there is a macro camelize that works with strings and atoms:

Macro.camelize("foo_bar")

Unfortunatelly it capitalizes the first character and does not support Unicode. From the docs:

This function was designed to camelize language identifiers/tokens, that’s why it belongs to the Macro module. Do not use it as a general mechanism for camelizing strings as it does not support Unicode or characters that are not valid in Elixir identifiers.

Luckily there is Inflex library that can camelize strings (among other useful things):

iex(3)> Inflex.camelize("first_name", :lower)
"firstName"

As you can see, it does have a :lower option which is exactly what we want.

The implementation is interesting too as it’s actually very short:

defmodule Inflex.Camelize do
  @moduledoc false

  def camelize(word, option \\ :upper) do
    case Regex.split(~r/(?:^|[-_])|(?=[A-Z])/, to_string(word)) do
      words ->
        words
        |> Enum.filter(&(&1 != ""))
        |> camelize_list(option)
        |> Enum.join()
    end
  end

  defp camelize_list([], _), do: []

  defp camelize_list([h | tail], :lower) do
    [lowercase(h)] ++ camelize_list(tail, :upper)
  end

  defp camelize_list([h | tail], :upper) do
    [capitalize(h)] ++ camelize_list(tail, :upper)
  end

  def capitalize(word), do: String.capitalize(word)
  def lowercase(word), do: String.downcase(word)
end

It converts the string to list of words and uses the [head | tail] convention to support the :lower option. Then capitalizes every word and joins them.

Next is to actually know how to do anything with keys and theirs values. In Elixir we can use for:

for {key, val} <- map, into: %{} do
  {key, val}
end

map here would be our data variable. We are returning the same map.

Let’s try to add Inflex in the mix:

for {key, val} <- map, into: %{} do
  {Inflex.camelize(key, :lower), val}
end

This would indeed camelize the keys, but left out all the values including our nested maps.

To that end we need to also deeply convert our values:

defp camel_cased_map_keys(map) when is_map(map) do
  for {key, val} <- map, into: %{} do
    {Inflex.camelize(key, :lower), camel_cased_map_keys(val)}
  end
end

defp camel_cased_map_keys(val), do: val

# and then
data |> camel_cased_map_keys()

If the value is another map, we recursively call this function again. If not, we keep the value intact by leveraging pattern matching.

This looks good unless we realize that data and datetime structs are maps too! Luckily we can pattern match on these structs and skip them as well:

defp camel_cased_map_keys(%Date{} = val), do: val
defp camel_cased_map_keys(%DateTime{} = val), do: val
defp camel_cased_map_keys(%NaiveDateTime{} = val), do: val

defp camel_cased_map_keys(map) when is_map(map) do
  for {key, val} <- map, into: %{} do
    {Inflex.camelize(key, :lower), camel_cased_map_keys(val)}
  end
end

defp camel_cased_map_keys(val), do: val

In some projects we might want to add a case for Ecto.DateTime or Timex.Ecto.DateTime as well.

If you stuble upon Caramelize library, note that it uses the above mentioned Macro.camelize underneath and so it does not support Unicode.

If you need to be automatically changing these keys back and forth for API requests, you should do a Plug-based approach. Look at Accent plug for inspiration. And if you are using JSON:API spec I added support for camelCase to ja_serializer.

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS