Why do IO lists not make use of the String.Chars protocol?

Hello, I just learned a couple of days ago that IO lists are a thing, and while playing around with them I ran into something that didn’t work when I expected that it would, and I’m curious if it’s something that just isn’t supported at the moment, or if there is some reason it shouldn’t work.

What I expected to work: a struct that implements String.Chars can be used in an IO list directly

Example:

defmodule Test do
  defstruct []
end

defimpl String.Chars, for: Test do
  def to_string(_v), do: "it worked!"
end

Using the contrived example above I expected the following lines would have the same result:

IO.puts "Aaaand...#{%Test{}}"
IO.puts ["Aaaand...", %Test{}]

…but the second line results in an argument error.

I can however make it work if I call to_string explicitly:

IO.puts ["Aaaand...", to_string(%Test{})]
Aaaand...it worked!

I’d love to know why this is the case (as well as if what I tried to do is silly and ruins the performance benefit somehow).

Elixir apparently delegates IO list processing to Erlang and/or the BEAM:

  @spec puts(device, chardata | String.Chars.t) :: :ok
  def puts(device \\ :stdio, item) do
    :io.put_chars map_dev(device), [to_chardata(item), ?\n]
  end

  defp to_chardata(list) when is_list(list), do: list
  defp to_chardata(other), do: to_string(other)

To get the behavior you expect, Elixir would have to call to_string/1 on each item in the list. I lack context for the design decision, but it’s an interesting question.

You already said it… Elixir would need to resolve the protocols before handing over to erlang, this would eat up about every advantage that IO-lists gave us before hand, especially in the case when there are only nested IO-lists.

Ouch ok yeah that makes total sense, and explicitly calling to_string is definitely the better compromise. Thanks for digging into the source like that!