What is the value of @type as alias?

While reading the Typespecs documentation something caught my eye:

Defining custom types can help communicate the intention of your code and increase its readability.

defmodule Person do
   @typedoc """
   A 4 digit year, e.g. 1984
   """
   @type year :: integer

   @spec current_age(year) :: integer
   def current_age(year_of_birth), do: # implementation
end

I found myself puzzled by this statement. The argument in question year_of_birth is already clearly indicating it’s a year. And the type cannot enforce the rule “A 4 digit year, e.g. 1984”. So what is the type spec adding?

It seems to me that what it’s adding is a step of cognitive overhead in understanding that what is being passed is an integer.

I’ve seen other examples of creating types for username and password that alias the String.t type and again I find this unconvincing since the function being spec’d almost certainly calls it’s arguments username and password so what is being added?

Where a "@type alias adds useful information I buy it. String.t is more expressive than binary and implies UTF-8 and purpose. But I struggle to think of other examples and, in general, it seems like aliasing in the manner described obscures information, rather than adding to it.

Are there useful counter-examples? What have I missed?

Thanks.

Matt

2 Likes

It can:

@type year :: 1000..9999

Sometimes it can help, sometimes it will not. I think that it highly depends on the context.

3 Likes

This is only valid at compile time, therefore I don’t bother to use it, because its too much noisy and Dyalizer is known to not be 100% correct all the time, when using the type specs.

Instead I prefer to do:

defmodule Person do
   
   def current_age(year_of_birth) when is_integer(year_of_birth) and year_of_birth > 0, do: #implementation
end

But, then I discovered this library:

https://hexdocs.pm/domo/Domo.html

Now I prefer to use it to write my types for each function.

Domo also uses type specs under the hood, but now they are hidden from eyes, not adding useless noise all over the place :slight_smile:

Don’t get me wrong, I love typed code, but not as it’s sone with the noisy way of type specs.

2 Likes

In this particular case, in order to adhere to what the typedoc says, the type should have read " A positive 4 digit year, e.g. 1984":

   @type year :: 1000...9999

And it will add more value, but they are just introducing the types, so it will make things complicated to start with ranges instead of integers.

1 Like

Are there useful counter-examples? What have I missed?

from the guides

Static code analysis

Typespecs are not only useful to developers as additional documentation. The Erlang tool Dialyzer, for example, uses typespecs in order to perform static analysis of code. That’s why, in the QuietCalculator example, we wrote a spec for the make_quiet/1 function even though it was defined as a private function.

3 Likes

Couple thoughts:

  • arguments don’t always have names (for instance, if all the heads pattern-match on literals or destructure the argument)

  • year is possibly overkill, but this style can be very useful when writing generic functions. For instance, here’s the typespec of Enum.chunk_while:

  @spec chunk_while(
          t,
          acc,
          (element, acc -> {:cont, chunk, acc} | {:cont, acc} | {:halt, acc}),
          (acc -> {:cont, chunk, acc} | {:cont, acc})
        ) :: Enumerable.t()
        when chunk: any

and here it is without any aliases:

  @spec chunk_while(
          term,
          term,
          (term, term -> {:cont, term, term} | {:cont, term} | {:halt, term}),
          (term -> {:cont, term, term} | {:cont, term})
        ) :: term

The aliases here don’t alter the meaning, but they make it clearer what the intent of each piece is.

Note that this isn’t quite like generics in stronger typing systems, because there’s no requirement that acc stay a consistent type between iterations.

4 Likes

Thanks hauleth.

I missed that a type can specify a value range. They don’t seem to have an example on the page I found that. I realise that it complicates the example but I think it also makes the example useful.

In general I think the issue is about whether you are actually clarifying how the arguments are named. I’m told there are good examples where this actually makes sense.

In particular I think using @type to name a return tuple:

@type number_with_remark :: {number, String.t}

  @spec add(number, number) :: number_with_remark

is a useful alias and in general the most valuable part of the typing for me so far is the ability to clarify return values. I’m still not convinced about introducing alias names for primitive types when the argument is likely to be clearly named. It would be useful to see a really good example.

m@t

1 Like

Hi eksperimental.

Thanks for the reply but I don’t follow you. To be clear I was looking for counter-examples where using a @type alias for a primitive typed argument such as String.t() was adding information.

m@t

Thanks al2o3cr

Good point about some arguments not being named.

I guess if I understand your other point what you are saying is about relating the spec to the function signature to make the spec easier to read?

m@t

Usernames and passwords could be anything. They don’t necessarily need to be a string. The password could have been a string of a certain length, or non-negative-integer, or a charlist, or a nonempty_charlist.
If it is not telling you anything, at least it is telling it to Dialyzer.

Personally, nor it is an overhead to read the spec of a function, even if its obvious, neither it is to write it. Because eventually you will realize that Dialyzer will warn you about something that you are doing and you are not supposed to. It is also less overhead for me to look at the spec and know what to pass and what to expect, without even reading the docs.

This is because it is easier to refer to year than to integer or to 1000..9999. It is also easier to read, and to maintain.
If you are only going to use the type once, it is overkill to create a type for that (you can solve it by defining the spec with the :: operator: @spec current_age(year :: integer) :: integer. But if you are going to reuse it, it is clearer in intention. The typical example would be the t/0 type in many modules. Such as String.t(), or Enum.t() or Keyword.t(). You may wonder, why on earth do we use Enum.t() instead of term(). Well, anything could be an enumerable provided the type implements the Enumerable protocol, but by using Enum.t() it is implying that is a term that implements the Enumerable protocol (even though Dialyzer won’t be able to check it). Well, same with year()

1 Like

Short answer: code comprehensibility. I want to be able to pick up any project 6 months later and the code to immediately make sense to me.

To me this is NOT obvious or intuitive:

@spec do_stuff(integer()) :: no_return()
def do_stuff(year) when is_integer(year), do: ...

This, however, is:

@type year :: 1000..9999

defguard is_year(x) when is_integer(x) and x in 1000..9999

@spec do_stuff(year()) :: no_return()
def do_stuff(year) when is_year(year), do: ...

The value of such aliases becomes apparent when your modules are 100 LoC or more; you find yourself wondering why the hell is this thing an integer or a String; you clearly remember it as a list!

The value proposition is not obvious in hobby projects. A lot of people make the mistake to think “meh, this seems more work than it’s worth right now so I am going to skip it” which severely bites them in the a$$ when they start participating in a commercial project.

(And none of that even mentions the kinda-sorta value you get from Dialyzer sometimes which still shouldn’t be ignored if you can afford to type-spec your code.)


So, TL;DR: documentability and readability. And ease of longer-term maintenance.

6 Likes

Definitely buy into the type aliases where they specify additional information. I hadn’t realised before this conversation that you could specify a range of legal values for an integer, for example.

I also like your use of defguard I think that is pretty clear. Is there a downside to using these liberally?

In fact your defguard example is so good isn’t the spec redundant (except, perhaps, for the no_return()? Is the use of the year alias here just to keep things consistent?

I also want to thank everyone who replied — you’ve greatly helped me understand the value of the specs and I’ve learned a lot. Thank you.

3 Likes

Save for accumulating too much of them, no. But there are ways around it, e.g. you can just extract them to a helper module.

Kind of, yes, but they also serve different purposes. @spec also goes into your ex_docs which can save your team time when reading / extending the code. In a tight-knit paid work team the benefit isn’t that big but in an open-source library you shouldn’t skip any @specs.

Plus, Dialyzer can help you every now and then. As mentioned above, if you can spare the extra dev time expense, do invest in @specs.

Nope, just depends on context. In this case the answer to your question is “yes” but it needn’t be always.

1 Like

Ah that’s something I hadn’t noted, that specs go into the documentation. Yes, I see that could be valuable.

Thanks for following up.

Matt