A case for inline type annotations

With pattern matching and multi clause function heads, I think having the type annotation in a single fixed location before all of the clauses is the cleaner approach.

And that point I do not care, whether I write $ foo -> bar or a bodyless clause def f(x :: foo()) -> bar() or whatever.

Though indeed I would have prefered if there was a name on the type line that tells me for what it actually is, to get a warning when after “code juggling” annotations get moved away from their functions…

4 Likes

I’d imagine you’d get a compilation error if you moved a function away and have a type line annotating either nothing or an incompatible function. Otherwise, there’s certainly no harm in accidentally forgetting to move the type lines for functions that have the same signature. Am I missing a scenario?

EDIT: sorry, you said it the other way around. I getcha now.

I like the @def for that reason. I thought of it myself but I’d rather not have an equivalent @defp, the annotations should be the same event if the function is exposed.

Actually I like Erlangs way of exporting functions better, and have never been a fan of Elixir’s syntax (despite being my favorite language). Syntax is not very important but yes, having the function name in the annotation would be time saving (also because you may group them at the top of the file).

Using Rust, Typescript, Kotlin, and Swift for inspiration, this could be:

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

And now there’s no question of readability, which furthers my point: modern languages have already solved this elegantly and concisely.

1 Like

Always appreciate you taking the time to chime in, Jose :folded_hands:

You’re making a case for type aliases, which is not a mutually exclusive concept to inline types. In your example, imagine:

$ typealias PaymentStatus = :trial | {:success, metadata} | {:overdue, metadata}
def log_payment_status(:trial :: PaymentStatus) do
def log_payment_status({:success, metadata} :: PaymentStatus) do
def log_payment_status({:overdue, metadata} :: PaymentStatus) do

Which is the best of both worlds: an explicitly defined function with inline typing, while maintaining a clean and readable signature.

This conversation is not about blindly copying other languages. It’s about the history of language syntax and where we’re at in 2025 – there are now concepts that clearly have consensus. I think optional types (e.g. String?) and inline typing have consensus, and should be heavily considered (if technologically possible in elixir).

I think you might have missed:

Even with that fairly trivial example, having the type signature defined three times is not exactly what I’d call concise. It also triples the work to keep the type signature correct with any refactoring, and increases the surface area for mistakes, IMO.

8 Likes

I didn’t miss that – I described in the different context of a type alias, similar to what’s available in Swift. Specifically, the key point is that a typealias in other languages is independent and can be used anywhere, not tied to one specific function. It is not a tool to absolve the requirement (or skip the inherent value) of inline types. In my example, the typealias is used to explicitly to describe the function signature, instead of the more disconnected $ annotation proposed for elixir.

Do you have a proposal of elixir inline types that’s more concise? Because unless you are intolerant of inline types altogether, :: PaymentStatus is quite concise to achieve all of the well know net positives that inline types provide.

I agree that signatures should be out-of-function-head, however I prefer a signature-per-head approach. This is because you lose resolution with a single-signature-for-all-function-heads approach, specifically with different-type-in different-type-out functions (which are a minority, but valid and useful).

That is, the contrived holistic signature here:

$ File.t | URI.t -> binary | Stream.t
def load_resource(resource)

def load_resource(%File{}), do: # return a binary
def load_resource(%URI{}), do: # return a stream

has strictly less expressive power than a per-clause version that can correlate data-in with data-out when typechecking:

$ File.t -> binary
def load_resource(%File{}), do: # return a binary

$ URI.t -> Stream.t
def load_resource(%URI{}), do: # return a stream

The exception being if we supported a compound variant that can correlate cause and effect, like

$ (File.t -> binary) | (URI.t -> Stream.t)
def load_resource(resource)

def load_resource(%File{}), do: # return a binary
def load_resource(%URI{}), do: # return a stream

Personally I prefer per-head over this invented compound form, with the expectation that an IDE or type warning would display the compound form for me when summarizing the possibility space.

Afaik that support is meant to exist. It’s at least a case that has been discussed as part of the type theory behind the type system.

1 Like

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!

10 Likes

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.

9 Likes

In the very same moment I see types like this, this means one of 2 things to me:

  1. You actually want load_ressource_from_file and load_ressource_from_url
  2. You want to find an abstraction over the result type, that covers all possible cases, and can be used the same at the call site
3 Likes

That wasn’t my example, it was someone else’s (@lud) that posted with the intent to be verbose, and I illustrated that it could be less verbose with modern constructs like type aliases. I personally wouldn’t ever write code this confusing in any language, please don’t attribute the example to me.

This is not what I suggested or implied. In Swift you can write:

typealias Transportable = Codable & Hashable

struct PushReply<Payload: Transportable>: Transportable {
    var joinRef: String?
    var ref: String?
    var topic: String
    var event: String?
    var container: Container<Payload>
    
    struct Container<ContainerPayload: Transportable>: Transportable {
        var response: ContainerPayload
        var status: String
    }
}

The typealias allows you to combine types and then cleanly represent that combination in function signatures and structures, rather than repeating the individual types themselves everywhere. While this is not exactly the same as your example returning one of the following statuses $ type payment_status = :trial | {:success, metadata} | {:overdue, metadata} (essentially and vs or) but the benefit of avoiding verbosity still remains.

By the way, your Kotlin code makes alot of sense to me:

fun handle(value: String): String = value.uppercase()
fun handle(value: Int): Boolean = value % 2 == 0
fun handle(value: List<Any>): Int = value.size

And if I were to write it intuitively in elixir with inline types, it would be something like:

def handle(value :: Int) :: String, do: String.upcase(value)
def handle(value :: String) :: Boolean, do: rem(value, 2) == 0
def handle(value :: List) :: Int, do: length(value)

What would it look like with the proposed $ notation?

Again worth repeating this is not my code, and you’re critiquing as if someone would actually try to write this? I’d fire someone immediately if I saw this commit, and if an LLM spit this out I would call up Sam Altman myself to complain.

Let’s move on from this code since it seems to have transformed into a red herring of invalidation, which is disingenuous to the post itself and the concerns that have been raised.

If the code you modified is not your actual suggestion on how you would type those functions, then you still have not answered how you would actually type those examples. :slight_smile: Otherwise, the only signature you posted so far is the one-line inc function, which we should all agree is not representative of the majority of the written code out there.

So in order to be concrete:

  1. How would you type the log_payment_status function? In your reply, you repeated the type signature and made the function partially match the content of the aliases, which as I explained in my previous reply, it is not natural to other languages, so it is confusing and repetitive (even if we completely disregard the proposed hostname function).

  2. How would you type the original hostname function then? Remember that type signatures cannot replace pattern matching and guards, as they are more expressive than type signatures. We can pattern match on integers, empty lists, sequences, binaries, and none of those can be expressed in any of the type systems of the languages above.

We can look at Swift and Kotlin code and translate that to Elixir but that’s not the task at hand. The job is to type existing Elixir code and support the language idioms. So how would you type the examples that have already been shown in this thread?

For completeness, here is how the hostname function would be typed with our proposed types:

$ URI.t() or binary() or nil -> binary() or 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
2 Likes

Even better, here is the relatively small URI module from Elixir. Can you go through every function and type them according to your proposal? elixir/lib/elixir/lib/uri.ex at main · elixir-lang/elixir · GitHub - the new type system would be quite similar to the existing specs, so that’s a good reference, but I can write a complete version as a follow up to yours. This will allow us to move the discussion into actual working code, rather than one-liners or fictional examples. Otherwise, we will all be guessing what you actually meant, which is not fair to you (nor anyone else).

4 Likes

That wasn’t my example, it was someone else’s (@lud) that posted with the intent to be verbose, and I illustrated that it could be less verbose with modern constructs like type aliases. I personally wouldn’t ever write code this confusing in any language, please don’t attribute the example to me.

I also gave an example with typealiases, before anybody else, to go partially in your direction.

Again worth repeating this is not my code, and you’re critiquing as if someone would actually try to write this? I’d fire someone immediately if I saw this commit, and if an LLM spit this out I would call up Sam Altman myself to complain.

This is your code though, a variation on mine. And while I agree that using type aliases makes it look more correct, it does not change the fact that a clause declares %URI{} :: URI | String? which is strange.

@lud I agree that you brought up typealiases (even before I mentioned) and I’m aligned with your thinking and examples – I should have called out this alignment more, instead of focusing on just the counter-intuitiveness of how this example’s signature was written.

I think Jose derailed a bit by stating I found this particularly confusing in the other example you posted and Not only it is repetitive and confusing, it also means we are incapable of expressing function overloads when it wasn’t my original code, and the only modification was going from String.t() | nil to String? to illustrate optional types will improve clarity and conciseness.

Do we all agree that this %URI{host: host} :: URI.t()) :: String.t() | nil is more concise+readable with optional type sugar (?) and typealiases, as you first mentioned? I would think the answer is universally “yes”.

The real debate is that even with modern syntax (typealiases, optional type String?, and potentially others), do inline types still not fit with elixir? Is $ just that much better for this language? Let’s continue on that path..

1 Like

Great suggestion Jose, here is uri.ex with just typealiases and optional types. There are a few other modern constructs that could potentially improve even more, but I think this is aligned to what’s possible in the current scope of work.

defmodule URI do
  @moduledoc """
  Utilities for working with URIs.
  """

  # Type aliases for better readability
  typealias Scheme :: String?
  typealias Host :: String?
  typealias Port :: non_neg_integer?
  typealias Path :: String?
  typealias Query :: String?
  typealias Fragment :: String?
  typealias Userinfo :: String?
  typealias URIString :: String
  typealias EncodingType :: :www_form | :rfc3986
  typealias QueryMap :: %{String => String}
  typealias QueryEnum :: Enumerable.t()
  typealias QueryPair :: {String, String}
  typealias Predicate :: (byte -> boolean)
  typealias Authority :: String?

  @derive {Inspect, optional: [:authority]}
  defstruct [:scheme, :authority, :userinfo, :host, :port, :path, :query, :fragment]

  # Core URI functions
  def default_port(scheme :: String) :: Port
  def default_port(scheme :: String, port :: non_neg_integer) :: :ok

  # Query encoding/decoding
  def encode_query(enumerable :: QueryEnum, encoding :: EncodingType \\ :www_form) :: String
  def decode_query(query :: String, map :: QueryMap \\ %{}, encoding :: EncodingType \\ :www_form) :: QueryMap
  def query_decoder(query :: String, encoding :: EncodingType \\ :www_form) :: Enumerable.t()

  # Character classification
  def char_reserved?(character :: byte) :: boolean
  def char_unreserved?(character :: byte) :: boolean
  def char_unescaped?(character :: byte) :: boolean

  # Encoding/decoding
  def encode(string :: String, predicate :: Predicate \\ &char_unescaped?/1) :: String
  def encode_www_form(string :: String) :: String
  def decode(uri :: String) :: String
  def decode_www_form(string :: String) :: String

  # URI creation and parsing
  def new(uri :: t | URIString) :: {:ok, t} | {:error, String}
  def new!(uri :: t | URIString) :: t
  def parse(uri :: t | String) :: t

  # URI manipulation
  def to_string(uri :: t) :: String
  def merge(base :: t | String, rel :: t | String) :: t
  def append_query(uri :: t, query :: String) :: t
  def append_path(uri :: t, path :: String) :: t

  # Private helper functions
  defp encode_kv_pair({key, _} :: QueryPair, _encoding :: EncodingType) when is_list(key)
  defp encode_kv_pair({_, value} :: QueryPair, _encoding :: EncodingType) when is_list(value)
  defp encode_kv_pair({key, value} :: QueryPair, :rfc3986 :: EncodingType) :: String
  defp encode_kv_pair({key, value} :: QueryPair, :www_form :: EncodingType) :: String

  defp decode_query_into_map(query :: String, map :: QueryMap, encoding :: EncodingType) :: QueryMap
  defp decode_query_into_dict(query :: String, dict :: any, encoding :: EncodingType) :: any

  defp decode_next_query_pair("" :: String, _encoding :: EncodingType) :: nil
  defp decode_next_query_pair(query :: String, encoding :: EncodingType) :: {QueryPair, String}?

  defp decode_with_encoding(string :: String, :www_form :: EncodingType) :: String
  defp decode_with_encoding(string :: String, :rfc3986 :: EncodingType) :: String

  defp percent(char :: byte, predicate :: Predicate) :: String
  defp hex(n :: byte) :: byte when n <= 9
  defp hex(n :: byte) :: byte

  defp unpercent(binary :: String, acc :: String, spaces :: boolean) :: String
  defp hex_to_dec(n :: byte) :: byte?

  defp uri_from_map(%{path: ""} = map :: map) :: t
  defp uri_from_map(map :: map) :: t

  defp parse_string(string :: String) :: t
  defp nilify_query("?" <> query :: String) :: String
  defp nilify_query(_other :: any) :: nil

  defp split_authority("" :: String) :: {nil, nil, nil, nil}
  defp split_authority("//" :: String) :: {Authority, nil, String, nil}
  defp split_authority("//" <> authority :: String) :: {Authority, Userinfo?, Host?, Port?}

  defp nilify("" :: String) :: nil
  defp nilify(other :: any) :: any

  defp merge_paths(nil :: nil, rel_path :: Path) :: Path
  defp merge_paths(_ :: Path, "/" <> _ = rel_path :: Path) :: Path
  defp merge_paths(base_path :: Path, rel_path :: Path) :: Path

  defp remove_dot_segments_from_path(nil :: nil) :: nil
  defp remove_dot_segments_from_path(path :: String) :: String

  defp path_to_segments(path :: String) :: [String | atom]
  defp remove_dot_segments(segments :: [String | atom], acc :: [String | atom]) :: [String | atom]

  defp join_reversed_segments([:/] :: [atom]) :: String
  defp join_reversed_segments(segments :: [String | atom]) :: String
end

defimpl String.Chars, for: URI do
  def to_string(uri :: URI.t()) :: String

  defp extract_authority(%{host: nil, authority: authority} :: URI.t()) :: String?
  defp extract_authority(%{host: host, userinfo: userinfo, port: port} :: URI.t()) :: iodata
end
1 Like

I don’t see how the ? could make sense. Is URI | String? supposed to mean URI | String | nil or URI | (String | nil)? Because it reads like the latter but works like the former, no?

1 Like