kccarter
Best way to pass many arguments to a function?
Looking for some advice on how a seasoned Elixir developer might go about implementing the creation of a map/struct that is dependent on several other maps.
Let’s say we’re modelling something like a Freight Shipping Order, aka “Bill of Lading”. Generally speaking, these have a lot of fields. In our case, we need to make several API calls to just get the right information to assemble one.
Consider this pseudo code, typos are to be ignored.
def fetch_freight_order(id) do
# These all just return a Map, constructed from a JSON response.
shipping_customer = call_api(...)
receiving_customer = call_api(...)
line_items = call_api(...)
freight_company = call_api(...)
# ... and maybe 4-5 more calls to an api we don't control ...
# To assemble a freight_order, we need all of the parts above.
freight_order = new_freight_order(
shipping_customer,
receiving_customer,
line_items,
freight_company,
# ... 4-5 more arguments
)
end
Option 1
Would Elixir developers pass each argument or would they be more included to pass a keyword list, map, or use some other pattern?
# Easy to get confused at the call site which order these are all in.
def new_freight_order(
shipping_customer,
receiving_customer,
line_items,
freight_company,
# ... 4-5 more arguments
)
%{
# Populate shipping_customer fields
shipping_name: shipping_customer["Name"],
shipping_street: shipping_customer["Street"],
# Populate receiving_customer fields
receiving_name: receiving_customer["Name"],
receiving_street: receiving_customer["Street"],
# ... maybe 50+ more fields ...
}
end
Option 2
If we pass a single map, then we can pattern match in the function’s signature but it gets rather unwieldy pretty quickly and the call site also gets a lot busier:
def new_freight_order(%{
shipping_customer: shipping_customer,
receiving_customer: receiving_customer,
line_items: line_items,
# a bunch more matches...
}) do
# Merge into a single freight_order
end
Option 3
Or perhaps use single pattern matches:
def new_freight_order(freight_order, %{shipping_customer: shipping_customer}) do
# Merge in just the shipping fields
end
def new_freight_order(freight_order, %{receiving_customer: receiving_customer}) do
# Merge in just the receiving fields
end
Option 4
Alternatively, would anyone create separate functions for each section?
def populate_shipping_customer(freight_order, shipping_customer) do
end
def populate_receiving_customer(freight_order, receiving_customer) do
end
I appreciate that style is subjective, but as new Elixir devs, we’re curious what patterns might be preferred over others. This use-case comes up quite a bit for us. A lot of the data we’re working with within our Elixir app (and showing to the user), requires a considerable number of discrete API calls just to gather the required data before we can assemble it into some sort of local representation.
Thanks in advance.
Most Liked
wojtekmach
I agree that around 4 or more function arguments, things start to become dicey.
I also agree that depending on situation, Ecto changesets might feel like an overkill.
Here’s a couple options below.
Keyword.validate! is a built-in way to ensure proper key names and you can even set defaults.
For doing a bit more validations, a good old recursive function that parses the keyword list key/value pairs is often enough. Or do it inline and even convert to a map right away (to later use the assertive map.field syntax):
Map.new(options, fn
{:host, host} when is_binary(host) -> {:host, host}
{:port, port} when is_integer(port) -> {:port, port}
end)
Beyond that, NimbleOptions — NimbleOptions v1.1.0 is a great option as it’s super easy to define basic validations, defaults, and it can ever create docs for you.
rvirding
A struct has one definite advantage over using plain maps is that you define what should be in that structure and what should not be there. It gives you much better control over the data. And as structs are implemented using maps it is just as efficient as using maps directly.
kccarter
We’re able to use structs in some places where we have deterministic fields for a given response. An address is a good example.
But we find ourselves “having” to use normal maps with string keys more often than not because of the amount of JSON data we work with that isn’t easily defined ahead of time. (This is equally problematic in something like TypeScript.)
An example might be running a report that aggregates a bunch of values for every SKU in a system, with the results being returned in the format:
{
"sku_001": { "title": "value", "color", "value", ... },
"sku_002": { "title": "value", "price", "value", ... },
"sku_003": { "width": "value", "height", "value", ... },
}
We also do a layout of layout in HEEX templates where we don’t know the “keys” ahead of time, and structs don’t allow fetching a field based on a key-string.
D4no0
The rule of thumb is to not have a lot of arguments for a function (for me personally 4 is the upper limit). The most dangerous thing is as you mentioned, should you mess up the order and you can be in trouble.
In this case going with a map argument is the way to go, however I would advise against Option 2, as it is generally discouraged to have a match where you literally match on all the fields.
What you can do instead is to use the . map operator, for example freight_order.shipping_customer, in this way you will have an exception if that key is missing.
If you want to ensure that the structure of the map is respected at compile-time please use Typespecs.
sodapopcan
A couple of recommendations:
Because APIs are volatile, perhaps you want to give your users a message about it and NOT LetItCrash™, you should have your API calls return error tuples and use them with with.
with {:ok, shipping_customer} <- call_api(...),
{:ok, receiving_customer} <- call_api(...),
{:ok, line_items} <- call_api(...),
{:ok, freight_company} <- call_api(...) do
# Put them all together
else
{:error, api_error} ->
# Handle error
end
In terms of responses, I would have a mapper to rename keys and then you could even use Ecto to validate the final result (via an embedded schema).
def map_keys(shipping_customer, receiving_customer, line_items, freight_company) do
%{
shipping_name: shipping_customer["Name"],
shipping_street: shipping_customer["Street"],
receiving_name: receiving_customer["Name"],
receiving_street: receiving_customer["Street"],
# ...
}
end
Here I actually wouldn’t use . in this case (even though it’s generally a good suggestion) to keep it simple and leave the validation for the final Schema.
Then you could make an Ecto schema for the final structure and validate it:
defmodule Thing do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :shipping_name, :string
field, :shipping_street: :string
# ...
end
def build(attrs) do
%__MODULE__{}
|> change()
|> validate_shipping()
|> validate_receiving()
|> # ...
|> apply_action(:update)
end
end
Then the whole with would look like:
with {:ok, shipping_customer} <- call_api(...),
{:ok, receiving_customer} <- call_api(...),
{:ok, line_items} <- call_api(...),
{:ok, freight_company} <- call_api(...),
mapped_attrs = map_keys(shipping_customer, receiving_customer, line_items, freight_company),
{:ok, thing} <- Thing.build(mapped_attrs) do
# Do stuff with `thing`!
else
{:error, %Ecto.Changeset{} = changeset} ->
# Handle data error
{:error, %SomeHttpLibError{} = api_error} ->
# Handle network error
end
In terms of number of params I definitely like to keep them low, but sometimes rules need to be broken! Not sure how many API calls you need to make, though, so as always, YMMV.







