Parsing a Struct String - Code.eval_string/3? Custom Parser? Other Options?

Another more robust option is to use the structure of YAML or equivalent JSON that looks like the elixir definition above and utilize Nestru library (bias warning - I’m the author) to turn the map into the nested structs like the following:

Install dependencies

Mix.install(
  [
    {:nestru, "~> 0.3.2"},
    {:yaml_elixir, "~> 2.9"}
  ],
  consolidate_protocols: false
)

Define the payload

All nodes with childs has the same named child_list for simplicity.

payload =
  """
  Parent:
    field: Value
    another_field: "Another value"
    child_list:
      - Child:
          child_field: Child 1 data
          child_list:
            - TypeOne:
                field: Value
            - TypeTwo:
                another_field: Some other value
      - Child:
          child_field: Child 2 data
  """
  |> YamlElixir.read_from_string!()
%{
  "Parent" => %{
    "another_field" => "Another value",
    "child_list" => [
      %{
        "Child" => %{
          "child_field" => "Child 1 data",
          "child_list" => [
            %{"TypeOne" => %{"field" => "Value"}},
            %{"TypeTwo" => %{"another_field" => "Some other value"}}
          ]
        }
      },
      %{"Child" => %{"child_field" => "Child 2 data"}}
    ],
    "field" => "Value"
  }
}

Define nodes

defmodule Parent do
  defstruct [:field, :another_field, :child_list]
end

defmodule Child do
  defstruct [:child_field, :child_list]
end

defmodule TypeOne do
  defstruct [:field]
end

defmodule TypeTwo do
  defstruct [:another_field]
end

Implement Nestru.Decoder for nodes

StructDetector.split_module_fields/1 returns the list of struct atom and fields from the given maps list (or a single map). Nestru will call it with an appropriate map to build the child list of nested structs according to the hint.

defmodule StructDetector do
  def split_module_fields(map) do
    list = List.wrap(map)

    try do
      {:ok,
       Enum.flat_map(list, fn a_map ->
         Enum.map(a_map, fn {module_string, fields} ->
           {Module.safe_concat([module_string]), fields}
         end)
       end)}
    rescue
      ArgumentError -> {:error, :nonexisting_struct}
    end
  end
end

require Protocol

defimpl Nestru.Decoder, for: [Parent, Child, TypeOne, TypeTwo] do
  def from_map_hint(_struct, _context, map) do
    if Map.has_key?(map, "child_list") do
      with {:ok, module_fields_list} <-
             StructDetector.split_module_fields(Map.fetch!(map, "child_list")) do
        {modules_list, fields_list} = Enum.unzip(module_fields_list)

        {:ok,
         %{
           child_list: fn _value -> Nestru.decode_from_list_of_maps(fields_list, modules_list) end
         }}
      end
    else
      # Empty hint, decode all keys as-is.
      {:ok, %{}}
    end
  end
end

Decode

{:ok, [{root_module, fields}]} = StructDetector.split_module_fields(payload)
Nestru.decode_from_map!(fields, root_module)
%Parent{
  field: "Value",
  another_field: "Another value",
  child_list: [
    %Child{
      child_field: "Child 1 data",
      child_list: [%TypeOne{field: "Value"}, %TypeTwo{another_field: "Some other value"}]
    },
    %Child{child_field: "Child 2 data", child_list: nil}
  ]
}

payload can start from any node that Nestru.Decoder protocol is implemented for and be of any level of nestedness.

And you can easily support new nodes by including their names in the list in defimpl.