smueller
A case for inline type annotations
With the announcement of 1.19 rc0 and the path to user-supplied type annotations, I want to make the case for inline types—both for function parameters and local variables—rather than the currently implied direction of out-of-band annotation signatures using a $ prefix. The examples shared so far suggest something like:
$ integer() -> integer()
def inc(i), do: i + 1
This pattern – separating types from function heads – is unusual and, in my opinion, a step backwards compared to almost every modern language that supports types.
Why Inline Type Annotations Matter
Clarity and Locality
Types belong next to the values they describe. Splitting types from the function definition forces developers to mentally reconcile two separate lines:
# Inline (clear and self-contained)
def inc(i: integer): integer do
i + 1
end
# Out-of-band (disconnected)
$ integer() -> integer()
def inc(i), do: i + 1
Inline syntax offers immediacy—everything needed to understand the function is in one place.
Developer Familiarity
Languages like TypeScript, Swift, Kotlin, Rust, C#, and even Python (via PEP 484) all use inline typing for a reason. It’s readable, discoverable, and familiar to modern developers. A $ line above each function feels more like a callback to the days of @spec, and misses the opportunity to modernize.
Better Tooling & UX
Inline types play better with IDEs and editors. They improve autocomplete, refactoring support, inlay hints, and inline docs. Detached signatures complicate tooling and reduce feedback during development.
Variable Type Annotations Are Just as Important
It’s not just function signatures. We need a way to annotate local variables inline:
x: integer = some_potentially_dynamic_value()
case y: String.t() <- some_api_call() do
...
end
Other languages with gradual typing (Python, TypeScript, Julia) allow inline variable annotations. Without this, dynamic code within functions remains opaque to tools and reviewers, limiting the power of gradual typing.
Obviously retrofitting types on elixir is complex, and everyone appreciates the care being taken by José and the core team. But inline types—both for function signatures and variables—are essential if Elixir wants to deliver a type system that feels natural, modern, and ergonomic.
Would love to hear thoughts from others, and to also hear from the core team why it appears that $ annotations have so much momentum already!
Most Liked
josevalim
Correct. There are several reasons for not inlining type annotations and you touched two of them:
-
Elixir already supports default arguments and pattern matching in signatures, adding a third component will make it confusing and hard to read, and your example shows it well
-
By keeping them together, it would be very easy to conflate implementation with specification. For example, if you accidentally remove one of the clauses while refactoring the
hostnamefunction, then type checking may still succeed. After all, if you keeping implementation and specification together, then you can accidentally remove both, and you won’t have a specification to catch such mistakes
You already posted a good example, let me post another one.
For example, imagine you are doing a payment integration that returns one of the following statuses:
$ type payment_status = :trial | {:success, metadata} | {:overdue, metadata}
You must likely have a function today that deals with those statuses like this:
def log_payment_status(:trial) do
...
end
def log_payment_status({:success, metadata}) do
...
end
def log_payment_status({:overdue, metadata}) do
...
end
One of the goals of the type system is to allow us to say that, if we add a new payment status, the code above should emit a warning. In this case, it doesn’t make sense to annotate inline, because each clause only handles part of the payments, and you want to guarantee it handles all statuses as a whole:
$ payment_status() -> ...
def log_payment_status(:trial) do
def log_payment_status({:success, metadata}) do
def log_payment_status({:overdue, metadata}) do
If we only had inline annotations, then you would be forced to rewrite the code above to this:
def log_payment_status(status :: payment_status()) :: ... do
case status do
:trial -> ...
{:success, metadata} -> ...
{:overdue, metadata} -> ...
end
end
Which is fine, but not what we’d write today.
Modern and ergonomic is not about copying what other languages do, is about making sure it fits with the existing patterns and idioms in the language. ![]()
josevalim
After sleeping on it, here is my criticism of inline annotations.
Too much repetition
For example, take encode_kv_pair. The different clauses are effectively variations of the same type, which are repeated over and over again:
defp encode_kv_pair({key, _}: QueryPair, _encoding: EncodingType) -> no_return when is_list(key)
defp encode_kv_pair({_, value}: QueryPair, _encoding: EncodingType) -> no_return when is_list(value)
defp encode_kv_pair({key, value}: QueryPair, :rfc3986) -> String
defp encode_kv_pair({key, value}: QueryPair, :www_form) -> String
In the example above, there was even a need to rely on inference to reduce some of the repetition. With a separate clause, the type signature is defined once:
$ query_pair(), encoding_type() -> string()
defp encode_kv_pair({key, _}, _encoding) when is_list(key)
defp encode_kv_pair({_, value}, _encoding) when is_list(value)
defp encode_kv_pair({key, value}, :rfc3986)
defp encode_kv_pair({key, value}, :www_form)
While I definitely prefer the second one, and some of it can be described as taste, there is no discussion the second one is more concise and less repetitive. You can see this happening over and over when comparing snippets, in almost every function that has more than one clause.
It obscures the actual signature
One of the benefits of type signatures is to provide a brief description of what the function does. However, if you only have inline type annotations, this can become very hard. For example, let’s look at your merge function and have it with the actual code:
def merge(%URI{scheme: nil} = uri, _rel: t | String) -> no_return do
raise ArgumentError, "you must merge onto an absolute URI"
end
def merge(base: t | String, %URI{scheme: rel_scheme} = rel) -> t when rel_scheme != nil do
%{rel | path: remove_dot_segments_from_path(rel.path)}
end
def merge(%URI{} = base, %URI{host: host} = rel) -> t when host != nil do
%{rel | scheme: base.scheme, path: remove_dot_segments_from_path(rel.path)}
end
def merge(%URI{} = base, %URI{path: nil} = rel) -> t do
%{base | query: rel.query || base.query, fragment: rel.fragment}
end
def merge(%URI{host: nil, path: nil} = base, %URI{} = rel) -> t do
%{
base
| path: remove_dot_segments_from_path(rel.path),
query: rel.query,
fragment: rel.fragment
}
end
def merge(%URI{} = base, %URI{} = rel) -> t do
new_path = merge_paths(base.path, rel.path)
%{base | path: new_path, query: rel.query, fragment: rel.fragment}
end
def merge(base: String, rel: String) -> t do
merge(parse(base), parse(rel))
end
It is really hard to tell the arguments it receives and the expected return types. You need to go clause by clause, build which one overlaps and which ones do not in our head. Maybe each of them handle a different type, maybe not.
However, if you have a separate type declaration at the top, regardless of the syntax, then it is immediately clear:
def merge(base_uri: t | String, relative_uri: t | String) -> t
# or
$ t | String, t | String -> t
def merge(uri_or_string, uri_or_string)
Incorrect type annotations
Some of your examples have clearly invalid type annotations. Let’s keep annotation and code together once more:
defp merge_paths(base_path: nil, rel_path: String?) -> String? do
merge_paths("/", rel_path)
end
defp merge_paths(base_path: String?, "/" <> _ = rel_path: String?) -> String? do
remove_dot_segments_from_path(rel_path)
end
defp merge_paths(base_path: String?, rel_path: String?) -> String? do
(path_to_segments(base_path) ++ [:+] ++ path_to_segments(rel_path))
|> remove_dot_segments([])
|> join_reversed_segments()
end
The second clause says "/" <> _ = rel_path: String? but it clearly cannot handle nils. The last clause says it deals with nils, but it does not. Here is another one:
# First argument doesn't deal with all Segments, only with part of them (empty lists)
defp remove_dot_segments([]: Segments, acc: Segments) -> Segments
We see a similar mistakes in the return types to split_authority and hex_to_dec. Furthermore, because you were relying on inference, many of these mistakes have been hidden. If you fully type the arguments, you will see this popping up more and more.
Of course, the type system would find these bugs in practice, but the fact those issues are popping so frequently shows there are fundamental semantic inconsistencies with inline type annotations, which we will explore next.
Type aliases awkwardness
Inference is a great feature to have and most of our work so far has been on inference. However, many teams on statically typed languages prefer to rely on inference as little as possible. Especially because inference may hide bugs in certian cases. For example, you chose to rely on inference for decode_with_encoding, to avoid repetition:
defp decode_with_encoding(string: String, :www_form) -> String
defp decode_with_encoding(string: String, :rfc3986) -> String
However, this clause has one issue: if you change EncodingType to have a new entry, you won’t have a typing violation in this function, because nowhere you defined it is supposed to handle all EncodingTypes. While in this case you will likely get a warning anyway, because it is all defined in the same module, you won’t be able to rely on inference when implementing clauses for a type alias defined in another module (as I showed in log_payment_status earlier).
Of course, the answer would be to annotate those types:
defp decode_with_encoding(string: String, :www_form: EncodingType) -> String
defp decode_with_encoding(string: String, :rfc3986: EncodingType) -> String
However, per the previous section, the definition above is invalid. A type alias means, by definition, that if you have typealias Alias = Type1 or Type2, you can replace the Alias by Type1 or Type2. That’s how they behave in all typed programming languages. This means you literally wrote this signature:
defp decode_with_encoding(string: String, :www_form: :www_form | :rfc3986) -> String
defp decode_with_encoding(string: String, :rfc3986: :www_form | :rfc3986) -> String
which, per above, is clearly wrong.
This puts us in a pickle. If type inference won’t help us catch type alias changing and we cannot use the type alias, there is literally nowhere we can annotate that this function is meant to handle all EncodingType, unless we define the type annotation separately. This is what Wojtek and I referred to earlier in this thread in the hostname function. The issue was not the syntax, the issue is that inline annotations are semantically at odds with pattern matching, which is accentuated by type aliases.
Summary
There are a couple other issues with your inline type annotations, such as the syntax being fundamentally incompatible (anyone who disagrees is welcome to change the parser and prove me wrong), but hopefully the above is enough to show they have enough syntactical and semantic issues when typing existing code. And that’s within a single module! The Elixir repository has 447 modules and over 5700 public functions, while the URI module has 29 of them. So these issued popped up when typing 0.5% of a single codebase.
But how can we be certain that having type annotations apart is better? Well, that’s how we have been adding annotations for the last 10+ years via typespecs, and new type system is meant to improve on the flaws of the existing typespecs. I acknowledge there are a separate discussion to have about syntax, in this thread someone already asked about parens being required or optional, or even the $ of itself, but it is clear having type annotations separate from clauses is the superior choice.
Finally, I have to say it is a bit frustrating that at no moment none of the cons above have been mentioned, posing inline type annotations as having only benefits and no trade-offs. In particular, I disagree with almost all items from your summary:
- No mental mapping of positional types to parameters
I actually agree with this one, having to positionally map types to arguments is one of the downsides of having them separate.
- Self-documenting function signatures
As shown above, that’s clearly false. Whenever a function has more than one clause, I need to parse through every single annotation to figure out the types it accepts and returns. The combination of inline annotations with type inference in your latest snippet only makes this harder and defeats any self-documenting purpose.
- Easier refactoring - types move with parameters
I disagree. While inline annotations makes it easier to move code to a new place, it comes with the huge downside that you can break code when you move it around, because you change the specification and implementation at the same time. Given the purpose of types is to help us find bugs, I’d rather err on making sure bugs do not go undetected.
- Better IDE support potential for inline type information
I disagree. I see no reason why IDEs would struggle with any of the approaches.
- Pattern-aware - leverages Elixir’s existing pattern matching strengths
Strongly disagree. Mixing inline annotations with pattern matching leads to excessive typing violations, as explained at length above, especially when aliases are used. And when relying on type inference to avoid repetition, as done above, allows bugs to creep in.
Still, I appreciate the time for this discussion because I know others would have the same questions. Now we can hopefully put this particular topic past us and focus on approaches more suitable to the language. Thank you.
castagnag
I apologize if I intrude to give the type-theorist viewpoint. José knows I am quite fond of having inline type annotations (if used with parsimony), but not at the expense of whole type annotations. Giving the whole type annotation is far more expressive than just typing function parameters (besides all the advantages already evoked in this thread). The simplest example I can think of is the or function or as it is defined in JavaScript: it takes two arguments and returns the first one if it is truthy, otherwise it returns the second one. In Elixir, we can write this as follows (assuming that falsy values are 0, “”, 0.0, and :false):
def or(x,y) do
if x != 0 and x!= "" and x != 0.0 and x != false do
x
else
y
end
end
You can define a type annotation that precisely describes the function:
$ type Falsy = 0 or "" or 0.0 or :false
$ type Truthy = not Falsy
$ ((Falsy, a) -> a) and ((Truthy and b, term()) -> (Truthy and b))
when a: term(), b: term()
where a and b are type variables.
I do not pretend that the type above is readable, but it exactly states what I wrote in English: “the function returns its second argument if the first one is falsy, otherwise it returns the first argument”. While this is not implemented in Elixir (yet?), we have running prototypes that can reconstruct this type for the unannotated code [see POPL25 conference]. There is no way to obtain such precision (crucial to precisely track types in branching) by typing the function parameters (unless, of course, you use two distinct function clauses, which is not the point).
The only type we can give to the parameters x and y is term() therefore deducing for or the type term(), term() -> term(), the one of all binary functions.
To summarize, whole type annotations are necessary to fully exploit the expressiveness of set-theoretic types.
josevalim
I am not aware of any language that behaves like you propose, where you are annotating part of an alias, instead of annotating the whole value and then exhaustively matching on each part of the alias later on. I found this particularly confusing in the other example you posted:
It also repeats the signature on every clause - which can become quite verbose - but has the additional confusion arising that some annotations are certainly untrue for that given clause. Take the first clause:
def hostname(%URI{host: host} :: URI | String?) :: String?, do: host
This clause certainly doesn’t deal with with String? but the annotation is there anyway. Not only it is repetitive and confusing, it also means we are incapable of expressing function overloads, where different arguments return different types. What you are proposing is equivalent to saying this Kotlin code
fun handle(value: String): String = value.uppercase()
fun handle(value: Int): Boolean = value % 2 == 0
fun handle(value: List<Any>): Int = value.size
should have a repeating type signature, which is clearly not true as seen above, but it would also lead to repeating return types:
fun handle(value: String | Int | List<Any>): String | Boolean | Int =
which comes with a massive loss of precision as you no longer capture the information that a “Boolean” is returned only if you gave it an “Int” type.
If your argument is familiarity, all languages I know of that support overloading and inline type signatures have each clause typed independently, instead of repeating the whole signature on every clause. So I objectively find this particular proposal to be more verbose, more foreign, and less powerful than your previous ones. And the issue of clause complexity (mixing pattern matching, default arguments and type signatures in the same construct) is still unaddressed in both.
As per my original comment, the way those languages would handle my example would be by rewriting the code as:
def log_payment_status(status :: payment_status()) :: ... do
case status do
:trial -> ...
{:success, metadata} -> ...
{:overdue, metadata} -> ...
end
end
If any of the languages you are using as reference allow partial matching of an existing type alias in the function signature, then I would love to see an example and the accompanying documentation to learn more!
wojtekmach
EDIT: José raised similar and more points in the reply above as I was writing this but I’m gonna post the following anyway in case anyone finds this useful.
Reading this code for the first time, my immediate reaction is:
def hostname(%URI{host: host} :: URI | String?) :: String?, do: host ^^^^^^^^^^^^^^^^ ^^^ ^^^^^^^^^
We’re already pattern matching on a %URI{} so writing URI again feels redundant but writing | String? is confusing, it’s not a nullable string, we’ve already established it’s an URI.
def hostname(url :: URI | String?) when is_binary(url) :: String?, do: hostname(URI.parse(url)) ^^^^^ ^ ^^^^^^^^^^^^^^^^^^^
This clause has a guard that ensures url is a binary so again, why treat url as possibly a URI or nil?
Reading it again, I suppose the principle is all we have is inline types so we need to repeat them. But, and this may come down to personal preference, to me repeating types over and over again significantly decreases readability.
This repetition could be error prone too, unless the compiler makes sure every clause has matching types. So there’s a potential downside, error proneness, with a potential guard rail, the compiler is smart enough to catch this. So there is nuance and tradeoffs. I was really missing in your initial post discussing nuance and tradeoffs. (This repetition point was just the first thing off the top of my head.) These type annotations don’t exist in the vacuum and need to fit with existing language features like multiple function heads, pattern matching and guards, and default values. Languages you mentioned, TypeScript, Swift, Kotlin, Rust, C#, Python, they have varied support for those features (mostly missing except for default arguments) and neither has all three. So yeah, I’m really curious what are the other tradeoffs of what you’re proposing.







