Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Turn type inference up to 1000 #14145

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open

Turn type inference up to 1000 #14145

wants to merge 16 commits into from

Conversation

josevalim
Copy link
Member

Prior to this PR, we performed type inference only in patterns. This pull requests adds type inference to most expressions in Elixir, including function calls, paving the way for us to add inference of guards.

The best way to show this is with an example. The following code:

def add_foo_bar(x) do
  x.foo + x.bar
end

Will automatically infer that x is a map, which has at least the .foo and .bar keys, where their values must be integers or floats. The return type is either an integer or a float.

There are about 10 TODOs I must tackle before merging this.

@josevalim josevalim changed the title Turn type inference up to 11 Turn type inference up to 1000 Jan 5, 2025
@kipcole9
Copy link
Contributor

kipcole9 commented Jan 5, 2025

What a great start to 2025 José. Will expression typing still work if an operator is implemented in a user function?

@josevalim
Copy link
Member Author

josevalim commented Jan 5, 2025

What a great start to 2025 José. Will expression typing still work if an operator is implemented in a user function?

Remote functions outside of the stdlib won't be used during inference but they will be used during type checking. In very simplified terms, type inference is how callers perceive a function. Type checking is how a function perceives itself. So imagine you write this code:

def add_foo_bar(x, y) do
  CLDR.Date.add(x, y)
  x.monthh # typo
end

Assuming CLDR.Date receives two dates, when we infer the type, we won't know that x and y are dates because we don't look at remote functions. Therefore, we will assume add_foo_bar expects any term as argument. However, when we type check, we will know x and y are dates, and because they don't have a monthh field, you will get a typing violation.

In other words, we will find bugs within add_foo_bar, but if someone calls add_foo_bar with not a date, because we don't know x and y are dates during inference (as inference does not go through remote calls), there will be no typing violation. So we find bugs from inside, but not from outside. Of course, this would all be a moot point once we have type signatures, as both callers and the function itself would be validated against the type signature.

EDIT: I believe we can improve this on Elixir v1.19 to perform inference using all of your app/package dependencies.

@@ -290,9 +347,61 @@ defmodule Module.Types.ExprTest do
<<x::integer>>
"""
end

test "requires all combinations to be compatible" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a risk of this being too restrictive and refusing valid dynamic programs?
I would have imagined inferring the dynamic union of string or charlist here.

Copy link
Member Author

@josevalim josevalim Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a very good question. So far our decisions around syntax constructs is that they will behave the same on both static and dynamic. In other words, we don't want type checking to behave differently depending on some flag, because that will only introduce confusion.

In a static program, the behaviour above is the better choice, as it reveals the possibility of a runtime error (a static program tells us about errors that can happen, even if they don't in practice), which would be a false warning in existing code base. I would say that's a fine compromise for two reasons:

  1. You can move the function inside the conditional: if some_condition?, do: String.to_integer(arg), else: List.to_integer(arg)

  2. You can use apply, which this PR changes to make it always dynamic

Another possibility is for us to introduce a function, dynamic, that converts a type into dynamic, so you could do: dynamic(mod).to_integer(arg) but we so far make all function dispatch the same, regardless if the module we are being called is dynamic or not, because stuff like checking for undefined functions, deprecations, etc, should happen regardless if the mod is dynamic or not.

Furthermore modules are defined at compile-time, which means we can often check and assert these properties at runtime rather than compile-time (generally preferable).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes perfect sense, thank you for the detailed explanation ! 💜

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants