It is true that, with a sufficiently simple type system, type inference is almost trivial. For example, writing a typechecker for the simply typed lambda calculus (STLC) is extraordinarily straightforward. However, note that the STLC includes explicit type signatures on all lambda-bound variables. Typing would be much more complex if this were not the case!
Types can depend on usage
As an example, consider the expression $λx. x + 1$. What should this expression’s type be? If we assume that $1$ has type $\mathrm{Int}$, then the expression should have type $\mathrm{Int} → \mathrm{Int}$, but how do we deduce that?
In the examples given in your question, you are considering inferring the types of binding definitions, like $\,\mathbf{let}\; y = x + 1$. In this case, type information always flows “bottom up”—we know that the type of $x + 1$ always has type $\mathrm{Int}$, so we can deduce that $y$ also has type $\mathrm{Int}$. But lambda-bound variables don’t work like this: they don’t have an associated expression that determines their value because their value is determined by their call site. So we have to examine the body of the lambda expression to observe how the variable is actually used in order to determine the argument’s type.
In languages with particularly sophisticated type inference, this principle can be taken even further. For example, consider the following expression:
$$\begin{array}{l}
\mathbf{let}\; \mathrm{identity} = λx. x \\
\phantom{\mathbf{let}}\llap{\mathbf{in}}\; \mathrm{identity}\ 1
\end{array}$$
What is the type of $\mathrm{identity}$? Let’s assume that our type system either does not support parametric polymorphism or does not support inferring parametrically polymorphic types. (More on that subject later.) In that case, we must assign $\mathrm{identity}$ the monomorphic type $\mathrm{Int} → \mathrm{Int}$, but the only way to know that is based on how the function is used. In general, the use site might be far away from the definition site, and there might even be several different use sites that conflict with each other! This requires some mechanism for propagating the type information across those distances and reconciling the different sources of information with each other (and producing error messages if the reconciliation fails).
Since type information can flow through the program in so many different ways, and across such long distances, this motivates the use of constraint solvers in type inference algorithms. Different parts of the program constrain the types in different ways, and the constraint solver reconciles the constraints.
Polymorphism is extremely complicated
Even global, constraint-based type inference can be relatively simple to implement if your type system is fully monomorphic. However, as soon as you choose to support parametric polymorphism, things become dramatically more complicated. Consider the following expression:
$$\begin{array}{l}
\begin{aligned}
\mathbf{let}\; \mathrm{identity} &= λx. x\\
\mathrm{add1} &= λx. \mathrm{identity}\ (x + 1)\end{aligned} \\
\phantom{\mathbf{let}}\llap{\mathbf{in}}\; \mathrm{map}\ \mathrm{add1}\ (\mathrm{identity}\ [])
\end{array}$$
The type of this expression is $\mathrm{List}\ \mathrm{Int}$, but how do we deduce that? There is a lot to figure out:
The type of $\mathrm{map}$ is known to be $∀a\,b.\: (a → b) → \mathrm{List}\ a → \mathrm{List}\ b$. When we apply $\mathrm{map}$ to arguments, we must instantiate the type variables $a$ and $b$ with concrete types, and we have to somehow automatically pick those concrete types based on the types of the function’s arguments.
In this example, the arguments are $\mathrm{add1}$ and $\mathrm{identity}\ []$. The second of those arguments gives us essentially zero information, because $\mathrm{identity}\ []$ itself could have type $\mathrm{List}\ \tau$ for any type $\tau$. So we have to use the type of $\mathrm{add1}$.
To even figure out the above, we must infer the types of $\mathrm{identity}$ and $\mathrm{add1}$. The type of $\mathrm{identity}$ must itself be polymorphic, as it is used at two different types: in the body of $\mathrm{add1}$ it has type $\mathrm{Int} → \mathrm{Int}$, but in the argument to $\mathrm{map}$ it has type $\mathrm{List}\ \mathrm{Int} → \mathrm{List}\ \mathrm{Int}$. Therefore, we must somehow automatically infer the polymorphic type $∀a. a → a$.
To infer the type of $\mathrm{add1}$, we must instantiate the polymorphic type of $\mathrm{identity}$ in the body to $\mathrm{Int} → \mathrm{Int}$ so that we can deduce that $\mathrm{add1}$ itself has type $\mathrm{Int} → \mathrm{Int}$.
Finally, we must match up the type of $\mathrm{add1}$ with the type of the first argument of $\mathrm{map}$ to instantiate both of its type variables to $\mathrm{Int}$, and only then do we know that the type of the overall expression is $\mathrm{List}\ \mathrm{Int}$.
As you can see, the flow of information here is not at all clear, and in general it’s not even obvious what inference is and is not possible. As it turns out, inference of parametric polymorphism in the presence of polymorphic recursion is known to be undecidable! So it really doesn’t take much to turn the “simple” problem of type inference into a provably intractable one. Of course, there are still very many useful type inference algorithms that can infer the types of some subset of programs, but maximizing the set of programs a type inference algorithm can successfully accept can be very challenging.
Subtyping makes things even more complicated
Subtyping makes type inference even more challenging, especially when a language has full untagged union types, not just inheritance. To see the issue, consider the following definition in TypeScript:
const first = (x) => x[0];
What is the type of this function? All of the following would be legitimate types:
Array<A> => A
[A] => A
[A, B] => A
[A, B, C] => A
[A, B, C, D] => A
And so on. Note that these types aren’t even all subtypes of one another: [A]
is a subtype of Array<A>
, but the other types are not (since they are heterogeneous tuples). Since there is no “most general” type for first
, we say that TypeScript lacks principal types, which means that first
doesn’t even really have any one type.
Other systems have more restricted forms of subtyping, and those systems may have principal types, but determining what they are may still be very difficult. The problem is that determining whether two types “match up” is no longer a matter of equality of types, but a more complex relation, the subtyping relation. This makes implementing the “constraint solver” part of type inference much harder.
Type inference in practice
As the above examples hopefully illustrate, type inference is extremely hard. Moreover, it can often produce very poor error messages when absolutely everything is inferred. Therefore, most type systems use a hybrid approach in practice: they use a mixture of explicit type annotations and type inference. In some respects, this makes the problem easier, since the user has provided some type information that does not need to be inferred. However, in other respects, it can actually make the problem even harder, since the user’s type annotations must be interpreted, checked for validity, and reconciled with the program’s inferred types.
Simple type systems do not need such complex type inference algorithms, but almost no practical type systems are as simple as the STLC. The design of type systems that support inference is therefore a challenge of threading the needle: how can we design a type system that can express the things users wish to express, yet also permits sufficient type inference to reduce the burden of writing all those types down? There are many different answers, and unsatisfyingly, there is no “maximally sophisticated” system—every type system feature has tradeoffs.
z: auto = x + y
, if z is an int then that's becausex + y
is an int. That may, in turn, follow from x and y each being an int, but conceivably (and in some similar real-world cases) the type inferred for z would not be the mutual type of x and y. $\endgroup$