sodapopcan

sodapopcan

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.

0
Post #21
lud

lud

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).

0
Post #22
smueller

smueller OP

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
Post #23
smueller

smueller OP

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).

0
Post #24
jswanner

jswanner

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
Post #25
smueller

smueller OP

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.

0
Post #26
christhekeele

christhekeele

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.

0
Post #27
LostKobrakai

LostKobrakai

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
Post #28
josevalim

josevalim

Creator of Elixir

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!

11
Post #29
wojtekmach

wojtekmach

Hex Core Team

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
Post #30