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!
First Post!
plcholder
Interesting, I have no authority on type theory, I had a similar thought also. I do wonder how would inline work with say a complex pattern match it may look a bit too noisy versus the out-of-band look, you make a good point with dx is it possible both can be implemented and one is just syntax sugar of the other etc?
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.
Last Post!
smueller
Interesting assessment, considering type annotations could be a year or more before full release. My perspective is that we’re just getting started..
So you disagree with @josevalim that inline types have the advantage of providing a mental mapping of positional types to parameters?
I don’t understand these words. Can you use a different programming language as an example where inline types cause arity one, and why it’s an advantage?
Popular in Discussions
Other popular topics
Chat & Discussions>Discussions
Latest on Elixir Forum
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex









