Idiomatic, real-world Elixir resources?

This is definitely partially against my principles because I don’t believe in universal truths most of the time. But I’ve allowed myself to compile a list of those possible idiomatic good practices – which IMO go well beyond Elixir itself.

  • Use @spec. However I’ll strongly recommend against that during rapid prototyping. It will just get in the way. The moment you find a module haven’t changed in 2 weeks however, go put specs on its functions and start chasing the green output of dialyzer.

  • Complement @spec with your own guards. If you know a datatype you use is actually a list, DO NOT guard it with is_list(param). Define a guard and use that instead:

@spec orders :: [%Order{}]

defguard is_orders(x) when is_list(x)

def eligible_for_discount?(orders) when is_orders(orders) do
  #...
end
  • Do not overdo docs. Many times they should even be superfluous. If your function is well-named and the parameter names hint at their purposes clearly enough you should be just fine by putting something like Checks if this batch of orders is eligible for a corporate discount or even without docs at all. Module docs are mostly important for widely used libraries. For your own open-source pursuits and commercial projects they are mostly a distraction. Go for GitHub’s wiki articles, or Atlassian’s Confluence pages, etc.

  • Don’t use import if you can help it. Resist it with all your might. Only use it if you have no other choice and your code will not work otherwise. If you really must use it, use the :only option.

  • Don’t overdo case and cond. Use function heads with hardcoded parameter values, say def length([]), do: 0 for example. Readability often means less coding lines (though certainly not always; too much brevity can make your code very cryptic but that’s a bigger topic I won’t tackle here).

  • Use macros and don’t be shy about it – but use them sparingly. For example when you want to transcode stuff and writing all functions manually would be tedious: that is a very valid usage. Macros should be used to make an otherwise annoying code generation easier and quicker. Some here in this forum get tempted to use them to introduce their own extra syntax in Elixir but I am very against this; these efforts usually attempt to emulate another language’s constructs and rarely seem aimed at actual productivity (at least not in the way I understand it; I realise this is a controversial opinion). They are usually used to rekindle nostalgia for paradigms the author fondly remembers from other languages. I am personally strongly opposed to these temptations; if using macros can both reduce the coding lines and improve readability then I’ll use them. But I am not a fan of the LISP approach where people make their own DSLs which are perfectly suited for the project… and nobody else except the author can maintain them or the project after they are introduced in it. We have to keep our work maintainable. That’s the professional courtesy I want to show to future maintainers of my code and thus I resist using macros in Elixir beyond simple readability improvement and reducing otherwise tedious coding work.

  • GenServer, Agent and many OTP primitives and derivatives should be used for (a) keeping state and (b) making your system resilient to partial failures. If you find yourself using them for other purposes then you are abusing them and should reconsider your approach.

  • As noted above, I like @enforce_keys for structs, provided I am not in the rapid prototyping phase. I will prefer compile-time errors to runtime errors 99% of the time.

  • Some extraneous code is acceptable if it makes using your modules more ergonomic and intuitive. For example I regularly make wrapper Config modules for my apps and libraries where I expose both generic put / get functions and specific put_* and get_* functions (say, put_timeout or get_batch_maximum). I’ve had colleagues argue with me over this and I’ll concede this is a personal preference but to me the intuitive reading of my code almost as if it’s English should win over small extra amounts of code.


I am probably missing several others but hopefully this gives you an idea of what I find to be good practices. Apologies for this becoming rather huge.

13 Likes

Very cool idea.

I’ve been writing typspecs in exported API functions in my work projects for the past 6 years, and I absolutely recommend this practice. When I started mentoring my current client’s team, typspecs were one of the first practice I introduced. They are currently mandatory, and we enforce them via credo.

I believe this is a good practice for improving code clarity. Imagine how it would look if Elixir or Erlang docs didn’t have typespecs. The same thing holds in our own code. I find it much easier to grasp and reason about a function when there are specs. In fact, in some cases, just looking at the specs can uncover some possible code design issue.

Since we write specs, we also use dialyzer during CI to detect possible problems. It’s far from perfect, but it’s better than nothing.

I’m not a fan of using guards in addition to specs. If an API function is accepting a list, I’ll use a typespec to indicate this, not a guard. Since Elixir is dynamic, and dialyzer can’t detect all problems, this means that the function might still end up accepting a non-list, in which case it’s behaviour is undefined. I’m mostly not worried about it though, b/c such issue can be prevented with good programming practices (like sanitizing the input at the edge of the system), and in my experience such situations are rare and they mostly occur in local dev, not in prod.

I quite like norm, but I’m currently not convinced that it’s a substitute for typespecs, because it’s a runtime check. It can complement specs though by helping us introduce tighter constraints at runtime. I still didn’t use it in practice but it’s definitely on my todo list.

5 Likes

I have a lot to say on this topic in general, but I’ll restrict myself to a few points of agreement about specs, structs and compile time checks:

  • Without specs the erlang and elixir docs would be far less useful. The primary benefit of specs is for documentation. Writing libraries without specs does your end users a disservice.
  • Dialyzer is extremely annoying and flawed, but it is the only practical mechanism we have to ensure that the specs we write match what our code is doing.
  • Structs take slightly more time to define than just using a map, but you get compile time guarantees that keys actually exist. I can refactor and change a key in one place and be sure that it gets updated everywhere else that references it.
3 Likes

I’m not here to say whether or not you should use typespecs. If you like them, use them. But saying that authors who leave off typespeccs aren’t showing “idiomatic” or “real-world” elixir is factually incorrect and disingenuous. I realize you didn’t specifically say this @sasajuric, but that’s what’s others are implying in this thread.

This is correct. Norm isn’t a replacement for typespecs. Norm is about specifying and validating interesting properties in your system, the majority of which can’t be expressed with a type system. If you want to use typespecs then Norm will provide a compliment to them and you’ll also get things like data generators and reasonable errors.

I was going to ignore this but I’m frustrated enough that I need to say something. Whenever I say that I want to continue to grow a system everyone assumes I mean, “move fast and break things” and the implication is that the people who value growth are careless, lack patience, and are otherwise thoughtless. I’m sick of this attitude. When I talk about growth I specifically mean growing a system without breakage. This design principle helps me and my team to continually deliver features and to build a system we can maintain over time. We do that by intentionally avoiding choices that will cause wide-spread breakages (such as @enforce_keys) and focus on building systems that are open to change. You’re (obviously) welcome to disagree with that mentality. The way we build systems may not be right for you and your team. But don’t for a moment assume that we make our design choices carelessly or because we just want to “build something quickly”.

Not sure if this was aimed at me, but just wanted to point out I didn’t say that. After all, my book doesn’t use specs too :slight_smile: I was considering having them, but ultimately didn’t go for it, because I estimated that the book is already packing quite a lot of information, and adding more stuff might confuse people and be counterproductive.

Perhaps that decision was wrong (though I didn’t hear anyone complaining about it so far), but that was the line of thinking. More generally, it’s to be expected that intro books leave out some of the things done in the “real world” to avoid hitting the readers with too much information at once. In my view, the value of a book is not just about what it teaches, but also about what it decides to leave out. Otherwise, we’d end up with a few thousand pages books which hardly anyone would be able to grasp within a reasonable amount of time.

2 Likes

Sorry, that wasn’t a comment directed at you at all. I edited my post to try to clarify but by that point it was probably too late :laughing:.

1 Like

There are two versions of “real-world” code, in my experience. One is applications (in the end product sense of the word) and the other is libraries/packages. I think that the vast majority of applications don’t use type specs, but most widely used libraries do.

5 Likes

Yes. I became a better Java programmer when I learned the distinction between the mindset of an author of a library/package vs. the author of an application.

The line can sometimes blur when you have part of an application serving as a library to other parts, but the overall point is a good one to raise. People accused of “over design” tend to be applying library concerns to applications. People accused of “irresponsible design” tend to apply application concerns to libraries.

3 Likes

Assumptions are a sad reality when people communicate only through text. Sorry that you got frustrated but no ill intent exists from my side.

It’s also very easy to assume that people are after rapid growth and this wasn’t aimed at you at all. It’s something that the business people do all the time and it’s something that I am personally sick of but it’s of course an uphill battle because they call all the shots and are free to pay 3 times for the same job because they didn’t listen the first time around. :003: With time I started getting less angry about it.

Same here. Unless we are on the same team and we actually have to make a good back-and-forth discussion to establish where the disconnect is coming from then we should do stuff like we feel is best for us. You won’t catch me trash-talking about it.

Mine was an honest question because I am not idolising any philosophy and I (hopefully) understand all techniques we can apply to our work have context and ideal conditions. Conversely, under other conditions they aren’t applicable at all (like insisting on having @spec everywhere during the initial project phases).

It’s an easy assumption to make because that’s the status quo at the 95% of everywhere I worked ever, and I am sorry if that felt like it was said with an ill will – it wasn’t.

2 Likes

I agree with app vs library point, but I’m not sold on the conclusion. I don’t have enough of a sample to know what “the wast majority of applications” does, but in my limited experience of trying both approaches for a couple of years, I definitely prefer app code with specs, because they help me understand what are the allowed inputs and what are the possible outputs, which in turn reduces the uncertainty when reasoning about the code.

Consider for example the following function:

def create_account(params)

What is this function accepting and what is it returning? To know this we need to look into the implementation. If we’re lucky, the impl is simple and we see everything in that function’s body. But quite often, we have to dig through a bunch of other private functions to piece it all together. This is cumbersome, energy draining, and error prone.

Consider in contrast the spec version:

@type account_params :: %{
        first_name: String.t(),
        last_name: String.t(),
        email: String.t(),
        password: String.t(),
        role: Role.t()
      }

@spec create_account(account_params()) :: {:ok, Account.t()} | {:error, Ecto.Changeset.t()}
def create_account(params) do

This already tells us so much more without forcing us to read the entire implementation. That is in my view a key benefit of typespecs. Combined with proper naming and well defined responsibilities they provide great help in reasoning about the code, library or app.

This is why I prefer to say that types are first and foremost about documenting, not about catching bugs. When a type checker complains, it means that there is a discrepancy between the spec and the implementation. From the standpoint of the desired program behaviour we don’t know what is wrong (in fact the program might even be correct), but we do know that the documentation (spec) doesn’t match the code, so we need to fix something to sync them.

I agree though that one should not be fanatical. I typically spec only API functions (i.e. exported funs which are not marked with @impl or @doc false), except for modules such as Phoenix controllers or Graphql resolvers, because specs don’t really help here at all. I might occasionally spec a private function if I estimate that it’s interface is complex and the inclusion of specs will assist the reader.

When it comes to docs/moduledocs, I don’t write them in the app code unless there are some important implications not obvious from the name/spec combo.

10 Likes

To clarify, my conclusion was that in my “real-world” experience people don’t tend to write specs for applications, not that they shouldn’t write specs. Personally I’m in favor of it for precisely the reasons you mentioned.

My feelings exactly.

3 Likes