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!

11 Likes

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?

There’s an extremely long thread about this from when this was first discussed, and the main reason is for backwards compatibility and the ability to undo it if the community decides they don’t like it.

Doing it inline would basically require it to be a 2.0 bump and make it difficult to “try it out” or reverse course.

With the current approach, once type annotations are fully supported, you could implement your own inline approach and if people liked it, adopt it, and maybe the language could evolve that way anyway, but there’d be more flexibility to try different things out along the way

4 Likes

What they said :up_arrow:

We do not need a way to annotate local variables. You might want one though. We already have when is_binary(y) and the compiler will hopefully know what type the functions return.

Do you want to be able to type a value to integer when the type system has determined it to be dynamic?

I haven’t seen that thread, but the logic seems completely unintuitive. Inline types aren’t some new breakout syntax that’s never been tried before – we’re talking about 50 years of programming languages new and old that have consistently proven (and chosen) the best thing. Why sacrifice so many benefits for the optionality of backing out due to community backlash? That’s just mitigating the wrong risks, while being indecisive at the same time.

If it’s significantly harder to implement the more proven and established way (inline typing), then the team should just say that. There would likely be more support for the annotated $ approach.

1 Like

Interesting choice of terminology to quibble – why not go further and ask whether we need any type system at all?

See the other recent thread about perspective on Rust / Kotlin / Scala developers and whether they would be compelled to switch. https://elixirforum.com/t/do-you-think-the-new-type-system-would-be-enough-to-attract-folks-that-are-also-interested-in-ocaml-haskell-and-scala

The direction that’s being advocating in this post is focused on building great language ergonomics and compiler trust, and the downstream effect is ultimately more elixir adoption, instead of continuing to be confined as a niche language

Let me start by saying i also thought that people are free to try out macros to inline type definitions if they want to experiment with that. You can always go from one style to the other with Elixir? No reason to think it’s set in stone i think?

But i work with the language Elm for fun every now and then which uses separate types, but at work i use C#, Typescript and sometimes even Pascal / Delphi. And i feel like the differences are not as crazy as you point them out to be? In Elm you have types for “variables” (just because it’s basically the same as a function) and often you only need to read the type of a function to get the gist of it (which is very elixir-like i think):

some_value : Int
some_value =
  42

stringify : Int -> String
stringify value =
  String.fromInt value

generate : ShoppingCart -> Account -> Invoice
generate cart account =
  ...

I don’t really see how the “clarity” of one syntax is objectively better. I would agree that there is probably more “familiarity” with the inline-style, but there a quite a few languages with seperated types that are closer to Elixir than e.g. Python is, i think? And on top of that the pattern matching in function heads and variable assignments are not something that’s very common in any of the languages you listed as “let’s look at them”, so there is more stuff going on in Elixir function heads already, i think? “Locality” and “tooling” i can only judge from using Elm and i think the Elm compiler is really good and i would rather spend my time 2h refactoring with the Elm tooling than 10min refactoring with C# tooling. I don’t see the reason why a different notation of types can make the tooling so much worse?

6 Likes

While there is personal preference to separate/inline type annotations, I don’t think they make a difference for the tooling. There’s no reason why IDEs should have a harder time with separate signatures.

Personally, I like inline type signatures, but having them together with non-trivial pattern matching could be very difficult to understand. Even in TypeScript, function heads can get pretty confusing, especially when some parameters are themselves functions.

I’m not sure how much value there is to explicitly typing local variables. Your examples are already handled by guards in pattern matching. And the compiler would need to generate those guards anyway, since the type check has to happen at runtime.

Finally, Elixir not having a static type system has always seemed like a red herring to me when it comes to reasons for not switching. Many of the most innovative and influential software developers have consistently gravitated towards dynamically typed languages, like various forms of LISP, Smalltalk, Erlang or even Ruby.

I know that the church of the static type has gained a lot of zealous followers on the internet, but there is always a trade off, no matter how much people like to pretend otherwise. In the end, somebody will necessarily be disappointed by some aspect of the type system, whether it will be the syntax, its capabilities or even its existence. There’s just no way to please everybody.

And then, even when the perfect type system has been implemented for Elixir, people will still refuse to switch. Because it was never really about types, but about people using what they know and not understanding that there is something actually different from the other technologies that all do the same things with different syntax.

8 Likes

I have met some of these people. Dynamic languages for them are a way to start doing stuff immediately. They view statically typed PLs as a hassle that’s always getting in the way. They want to get on with it.

I sympathize with that, a lot. But I feel that this mindset being brought to the work place is a very thin line to tread: one between “let’s deliver this in a reasonable timeframe” and a good old impatience. The former I respect. The latter: I don’t. Sprinting past proper tech due diligence is one of the best ways to accumulate tech debt.

Paying tech debt is not fun. It’s often extremely difficult, too, and I have witnessed it killing commercial projects.

All that being said, Elixir strikes a very good balance. But as your codebase and complexity grows, lack of static typing becomes more and more source of development slowdowns. Even that can be mitigated somewhat… but it does require paying the tech debt periodically. Which almost nobody wants to do.

7 Likes

Well said, static types save cost in the long run, they can help set the programmers intentions with the right invariant’s. I love writing elixir, but i always have that weird feeling that I wrote something wrong somewhere, that will comeback to bite me maybe not now, maybe in a few weeks, years etc ofc BEAM idiom of let it crash, does mitigate a bit of the worries but it is still a far cry from something like rust were you can guarantee things hold in most cases & you don’t need to write insane conditionals just to make sure some input is the right type

2 Likes

It’s a matter of taste maybe. In Haskell you have the annotations on top of the functions and I like it.

Elixir supports multiple clauses, so from that function:

def hostname(%URI{host: host}), do: host
def hostname(url) when is_binary(url), do: hostname(URI.parse(url))
def hostname(nil), do: nil

You could get something like this:

def hostname(%URI{host: host} :: URI.t()) :: String.t() | nil, do: host
def hostname(url :: binary()) when is_binary(url) :: String.t() | nil, do: hostname(URI.parse(url))
def hostname(nil :: nil) :: nil, do: nil

And I think it’s pretty unreadable.

Or maybe the type system will not collect all clauses (I’m sure it will, but it may not collect all clauses declarations), so you may have to do something like this:

def hostname(%URI{host: host} :: URI.t() | String.t() | nil) :: String.t() | nil, do: host
def hostname(url :: URI.t() | String.t() | nil) when is_binary(url) :: String.t() | nil, do: hostname(URI.parse(url))
def hostname(nil :: URI.t() | String.t() | nil) :: String.t() | nil, do: nil

Let’s simplify a bit:

$ has_hostname :: URI.t() | String.t() | nil
$ maybe_hostname :: String.t() | nil
def hostname(%URI{host: host} :: has_hostname) :: maybe_hostname, do: host
def hostname(url :: has_hostname) when is_binary(url) :: maybe_hostname, do: hostname(URI.parse(url))
def hostname(nil :: has_hostname) :: maybe_hostname, do: nil

But really my favorite is to separate the thing with a function head:

def hostname(uri :: URI.t() | String.t() | nil) :: String.t() | nil

def hostname(%URI{host: host}), do: host
def hostname(url) when is_binary(url), do: hostname(URI.parse(url))
def hostname(nil), do: nil

Note that I chose to use the function head syntax and the :: operator because if we support types only in function heads for now, and if it gets adopted and there is no going back, then we can allow it later in actual function clauses, especially when there is only one clause.

With $ + arrows well it’s a different syntax so it may be harder to inline those in functions, but I guess it’s feasible.

My point is that a type system needs to see functions as a whole ('cause you cannot chose a specific function clause to call from the outside – by other matters than passing the right arguments of course).

7 Likes

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 hostname function, 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. :slight_smile:

36 Likes

Very good point!

One could argue that if you remove a clause, or call the function with a new payment type, the type system could emit a warning at the call site.

I’d answer that while it’s true, Elixir being a dynamic language it will not always be possible to know the value of the payment type in advance (we got it from String.to_existing_atom maybe). While in/above the function, the type system can figure out all valid code paths.

1 Like

Thats a stellar example, which also show separation of concern, with one of Erlang’s super power, multiply function clauses.

I think most people that want inline type annotations are coming from those languages and it might be their first time with a BEAM language. If they stay here long enough, they’ll learn that the grass can actually be greener.

1 Like

Yes, we definitely would! But since we are discussing type annotations, I approached it exclusively from the type annotation angle.

3 Likes

I like that more than anything else, but I am still all in for not violating the Elixir 1.19- syntax, which might be achieved with a familiar and easily guessed by any developer module attribute.

@def hostname(uri :: URI.t() | String.t() | nil) :: String.t() | nil

# normal head, if desired
def hostname(arg)

# Implementations
def hostname(%URI{host: host}), do: host
def hostname(url) when is_binary(url), do: hostname(URI.parse(url))
def hostname(nil), do: nil

1 Like

With all due respect, but God forbid.

1 Like

Not only do I like types on their own line, I prefer it. I also really like the $ syntax. it’s concise while being easy to understand, doesn’t unnecessarily repeat the function name (consistent with every other kind of function annotation), and devoid of the gross ::… and I do mean “gross.” Something about putting spaces around it gives me trypophobia vibes :anxious_face_with_sweat:

2 Likes

:: is the type operator in many languages, it feels very natural to me. Honestly I’d rather have $ as a valid variable name character for streams or stuff like that.

Oh I know it is, but it doesn’t change my feelings about it :grin: Elixir shirks many norms and that’s one of the reasons I love it. It’s why I also dislike “familiarity == adoption” arguments. A couple of such arguments that have actually been made on this forum are 1) overhaul the syntax to use curly braces, and 2) to make it object oriented.

2 Likes