What are your recommendations on method signatures and return values?

Based on our discussion here and to create some kind of guide for new players…

What are your guidelines and recommendations on method signatures and return values?

Since everyone can get creative in a dynamic language like Elixir you probably have some guidelines in your companies on how to define new methods and what should they return so you have easier code transfers from one programmer to another.

For example:

  • How many parameters is too much for a function? When do you rather create a structure to contain the incoming data?

  • Do you rather use single parameters or do you prefer receiving a map that you map and deconstruct?

  • What is your ordering of parameters? Do you put the most static parameters to front or back?

  • Do you override methods with specific mappings or rather have one method with a case inside?

  • What do you return from methods? Are there methods that are returning just plain values in your projects and when do you switch to {:ok, data…} tuples?

  • Do you return {:ok} or just plain :ok? For me {:ok} is more consistent with {:ok, data}, for others it’s not.

  • How many return values do you put in your return statements? Just one or two? For example, {:ok, data}, {:ok, data1, data2}

  • When do you create a return structure?

More questions will come from the discussion.

5 Likes

I’m heading off for the weekend after work and since I ran my mouth pretty hard about asking questions in that other thread, I wanted to respond! There is a lot of content here, maybe a bit much for one thread (maybe not). I’d love to talk about most of it but since I don’t have much time I’m gonna zero in on the return values because that one interests me and is also related to “let it crash”.

In short, {:ok, resp} and {:error, message} specially is simply a convention used when something can go wrong. @stefanchrobot had a perfect example in this answer. As illustrated there, it’s often used where an exception would be thrown in other languages. I don’t have a lot of experience in languages where frequent exception-throwing is the norm but as I see it, this enforces explicit error handling at the source and frees up exceptions for cases that are truly “exceptional”. Without getting too much into it, this is where “let it crash” ties in. If you know how to handle something, by all means handle it! But ideally do it through some kind of well-formed return value and leave exceptions to be caught by supervisors. I could get more into this but trying to stay focus :sweat_smile:

So getting back on track, you essentially want to use the tuple convention when you need some kind of status code, and it doesn’t have to be :ok/:error, again that is just a convention. You could have a function that makes an HTTP request and could have return values like {200, "body"}, {400, "body"}, {500, "body"} etc. If your functional doesn’t need to check a status, for example String.capitalize/1, just return a bare value. It would be pointless, not to mention super annoying, if String.capitalize("hello") returned {:ok, "Hello"} since there is nothing else other than :ok to match on. A string is always going to successfully capitalize and if doesn’t, there is something seriously wrong and let it crash :slight_smile: (That is a super contrived “let it crash” example but I’m kind of rushing here).

Lastly, a simple :ok it returned when there is no other meaningful data to return in the success case. If the error case doesn’t have a message to go with it (which would be weird) you could just return :error, but generally it has a message so they are wrapped in a tuple. You could also just return a bare string in the error case if you really wanted—again, these are all just conventions. IE, there is no need the different return possibilities to be wrapped in the same data structure. For example, ExUnit.Callbacks.setup/1 can return :ok, {:ok, %{}}, or simply %{}. It pretty much comes down to {:ok} is just weird because a one-element tuple doesn’t make any sense. And in fact, it’s not as inconsistent as you might think since in Haskell (and possibly other functional languages), tuples of different lengths aren’t considered to be of the same type!

Anyway, I hope this helps a bit. I apologize that it’s a bit verbose—I would normally try and edit it down, but I’m now late for work and still have to pack for the weekend!

Edited to fix a small but significant typo: (I wrote “consistent” instead of “inconsistent”!)

Ok, to elaborate more on this… if you have a multiple errors that a method can return, say file doesn’t exist, file is currently locked by another process, file is too large to process.

Would you return {:error, {:file_too_large, “file path”}} or just {:err_file_to_large, “file path”}, I would choose the former, just asking what do you prefer.

Good question! I’ve never run into that. I think that comes down to taste. In these cases I like to do some “wishful programming” and see what the implementation looks like

case MyFile.open(file) do
  {:ok, contents} ->
    contents

  {:error, {:file_too_large, path}} ->
    path

  {:error, {:something_else_is_wrong, path}} ->
    path
end

vs

case MyFile.open(file) do
  {:ok, contents} ->
    contents

  {:file_too_large, path} ->
    path

  {:something_else_is_wrong, path} ->
    path
end

I personally prefer the second as it’s just more concise. Since I would hope that anything other than :ok would be an error, I don’t feel adding the extra :error tag adds much value. But I really feel this is a of taste. If you do like the :error tag, {:error, :file_too_large, path} is also perfectly legit. You did ask about tuple size. For me I generally think 2-3 is good. 4 is also good but starting to push it. I pretty much avoid 5 completely and would use a map at that point. But I really stress that this is a matter of taste and what you find readable.

I really like the approach described in this, though I need to add that I never managed to work on a codebase, which consistantly did that. It’s for sure overhead, but on the other hand I really like the explicitness.

4 Likes

IMO this choice is very context-dependent and driven by usage:

  • if the error isn’t going far (used by another function in the file) then the second form is shorter
  • on the other hand, if the error is part of an API it’s easier to document and for callers to handle an {:error, any()} result than {atom(), any()}
1 Like

Actually, this makes a lot of sense, I like it… With this approach there is clear understanding that when an error the method returns an error it is announced with :error atom and you don’t have to think about if the first value is just an value or it’s an error.

So following code is what I will use… It’s more code but it speaks more loudly…

2 Likes

Just a simple map and not any kind of defstruct, for example OrderProcessingResult? I’m not sure if people use defstructs or it’s just too much hassle.

hmm this looks fine until you forgot to implement format_error on a module

Oh god. I did just skim over the article and it mentioned the places I had read before. I’m mostly in favor of the {:error, exception} return value instead of {:error, something}. Exceptions have API to be turned into strings, they however can include structured data, which might be interesting if the caller wants to log the error. I don’t think the exact implementation shown makes too much sense.

3 Likes

I first saw that done in the exceptional library. I advocated for it in my talk at last year’s ElixirConf US, but I’ve only used it for one particular situation.

Note: I haven’t actually used the library at all, just the approach of returning {:error, exception}

1 Like

Some thoughts:

I will typically not return {:ok, <data>} unless the function may also return {:error, <data>}. The extra ceremony of the result tuple doesn’t really provide any value if the only options are the value or raising/crashing/other non-result.

Just :ok. At first I did return {:ok}… not so much because I gave it any thought but it was my default because I was just starting out with Elixir, and as you point out, it felt consistent. I later reverted all that to just be :ok. As I got better versed with prior practice in Elixir, the common libraries used like Ecto, and having made use of a number of Erlang OTP modules, there seemed to be a preference to not wrap a plain :ok atom return value in an extra data structure. So while {:ok} felt more immediately consistent with {:ok, <data>} I chose to have greater consistency with common practice. I expect that anyone newly coming to my code would be on slightly firmer ground if my code met familiar existing practice.

And maybe that’s the guidance I would really offer. Any set of documented guidelines will probably be better if some deference is given to historic practices… using perhaps the Elixir/Erlang Standard Libraries & OTP as a reference point… or at least explain why those historic practices are not being adopted when they might otherwise apply. They might not be the best, or even clearly consistent (for example, I see Erlang’s ETS module returning <data>, {ok, <data>} | {error, reason}, ok, but also true) but in general they are expected.

Other notes related to your questions (from my point of view).

Function parameters I will typically make explicit/individual required parameters. Some deference in ordering is made to how a function might be part of a data transformation pipelines such that |> usage is more natural. Optional parameters are Keyword lists & maps. The exception to this in my code is when the “record” (generically speaking) is the parameter. Changeset functions with Ecto are a good example of this, both the starting data struct and the changes are single parameters (usually) because the record itself is the value, albeit a complex one.

Return data will in part depend on if I expect the function to participate in data transformation pipelines or not. If so, the return value is built to facilitate that usage across the functions that it might be expected to work with. Otherwise it really depend on the consumer of the result.

1 Like

Can we just return an exception without raising it?
Also, how can we typespec a custom exception? I see very little info about exceptions in the docs

Yes. Exceptions are just specialized structs. Instead of raise ArgumentError, "Something" you can do ArgumentError.exception("Something") and you get that struct. You can then later do raise exception if needed.

As for typespecs I’d use Exception.t.

See Kernel — Elixir v1.13.4 on how to create custom exceptions as well as Exception — Elixir v1.13.4 on the behaviour to implement (and possibly overwrite the default implementations for).

1 Like

Hmm not sure how we can get any additional parameters with this approach

Exceptions are structs. You can put any data you need on them – and due to the behaviour of exceptions still make sure those can be transformed to a string based message.

hmm so if we used Exception.t() how we describe additional data which this custom exception handles?

I might be doing something wrong, but I’m using custom exceptions and a simplified version looks something like this:

defmodule MsbmsSystError do  
  @type t :: %__MODULE__{
          code: Types.msbms_error(),
          message: String.t(),
          cause: any()
        }

  @enforce_keys [:code, :message, :cause]
  defexception code: :undefined_error,
               message: "undefined error",
               cause: nil
end

When I reference that in other typespecs I just do something like:

@spec some_function() :: :ok | {:error, MsbmsSystError.t()}

I can also raise, etc. using that definition. I haven’t met any friction with that so far and everything that deals with handling exceptions works as expected so far.

Thanks for sharing your experience

I also strongly believe the point @OndrejValenta raised.
Proper error handling in MVP to enterprise-level apps is very crucial. So an official guide about how to organize code around errors will be really valuable. At least mentioning some patterns in the guide would do the job.

As we saw there are a lot of third-party articles about it and some even can cause lot of trouble in the long run if you didn’t seriously take a look

Elixir has freedom, but too much freedom can shoot our own foot :slight_smile: so a good official guide about this would be super useful

3 Likes