TL;DR: The aim of this library is to manipulate function returns in a more simple/idomatic/“standard” way rather than to standardize the function returns themselves.
It’s all about control-flow (combine/pipe functions with multiple returns types) rather than “all library developpers should always return :ok
/:error
tuples”.
First of all, thank you all for your answers.
They made me realize something: the purpose of this library is very clear in my mind, but the description I gave is not.
So I’m going to try to rephrase it.
Firstly, and this is my fault, I should never have used the term “standardize function returns”. Here I’m not promoting the fact that all functions should return a :ok
/:error
tuple. Of course an empty?/1
function, for example, should return a boolean, of course a get!/2
function should just return the data (since very often there’s a get/2
function in the same module that already returns a :ok
/:error
tuple).
The main use of this library is to manipulate these results in a simpler/“standard” way.
I’ve talked a bit about this in the project README (but not enough I guess), but you can think of StdResult as a new way of doing control-flow using :ok
/:error
tuples.
Originally, my problem stems from the use of with
where the more operations there are to perform, the more complicated it becomes to write else
. I know that this is a fairly recurrent problem and that many developers have already had this problem. Several posts/libraries exist on the subject and try to solve the problem. One example is this proposal: https://elixirforum.com/t/with-statement-else-index/56914.
StdResult is a library that provides an answer to the problem in specific cases.
Perhaps an example would be more telling. Let’s take this piece of code:
def update_user(updated_user, state) do
with true <- User.exists(updated_user.id),
{:ok, country_code} <- Country.to_country_code(updated_user.country),
true <- User.validate_phone_number(updated_user.phone),
old_user <- User.get(updated_user.id) do
new_user =
old_user
|> Map.merge(updated_user)
|> Map.put(:country, country_code)
MyDb.put(state.db_conn, new_user)
{:ok, new_user}
else
_ -> IO.puts("Something bad happened")
end
end
Here, if you need more details on the step that failed in the else
block, there are 2 solutions:
Solution 1: wrap each function call with a tuple
with {:user_exists, true} <- {:user_exists, User.exists?(updated_user.id)},
...
else
{:user_exists, false} -> IO.puts("The user doesn't exists")
...
end
The main problem with this solution is that very quickly the code becomes complicated to reread and too verbose.
Solution 2: wrap functions into another that returns a :ok
/ :error
tuple
with :ok <- validate_user_exists(updated_user.id),
...
else
{:error, msg} -> IO.puts(msg)
...
end
def validate_user_exists(id) do
case User.exists?(id) do
true -> :ok
false -> {:error, "The user doesn't exists"}
end
end
Everyone can have an opinion on this solution, but personally sometimes I don’t want to have to scroll or press on my shortcut to see/modify the error I’m going to get, or even have a dozen functions in a module where their only use is to convert a term into a :ok
/:error
tuple.
The solution I propose is as follows:
import StdResult
def update_user(updated_user, state) do
updated_user
|> then(fn user ->
case User.exists(user.id) do
true -> {:ok, user}
false -> {:error, "The user doesn't exists"}
end
end)
|> and_then(fn user ->
case Country.to_country_code(user.country) do
{:ok, code} -> {:ok, Map.put(user, :country, code)}
{:error, _} = err -> err
end
end)
|> and_then(fn user ->
case User.validate_phone_number(user.phone) do
true -> {:ok, user}
false -> {:error, "Invalid phone number"}
end
end)
|> map(&(&1.id |> User.get() |> Map.merge(&1)))
|> inspect(&MyDb.put(state.db_conn, &1))
end
I hope that’s clearer. Let me know if it isn’t.
I know, but I couldn’t think of a better name for this macro. If you have, don’t hesitate to suggest
Also, the macros ok!/1
and err!/1
raise during a pattern match, so it’s not that far from convention.
I like it! It will be on the next release, thanks !
What do you mean by “the reader needs to understand your way of thinking” ?
What more do you need when you say “in practice your library doesn’t give developers enough flexibility” ?
Why do you need to early raise ? Because you expect a value ?
Then simply use StdResult.expect/2
or StdResult.unwrap/1
env_value = System.fetch_env("PORT") |> expect("You need to define the environment variable PORT")
But basically, for your example, the equivalent of your code would look like this:
import StdResult
"PORT"
|> System.fetch_env()
|> to_result()
# `System.fetch_env(env) || raise message` is more common in config files
# So just use `inspect_err/2` :)
|> inspect_err(fn :error -> raise "PORT env required" end)
|> map(&String.to_integer/1)
|> and_then(fn port ->
if port >= 0 do
{:ok, port}
else
{:error, "PORT must be a positive number, got: #{port}"}
end
end)
As for the rest of your message, in short then_on_ok/2
is an and_then/2
and then_on_error/2
is or_else/2
.
And using functions is a choice, because I don’t like using macros when I can simply use functions.
@sodapopcan I think I’ve answered your comment in this one, but don’t hesitate if you have any questions.
I’ll update the current post and README for the next version.