Expanded Subtyping for Generic Function Types

Summary

Extend the subtyping relation for function types to relate generic function types with compatible instantiated function types.

Motivation

As Luau does not have an explicit syntax for instantiation, there are a number of places where the typechecker will automatically perform instantiation with the goal of permitting more programs. These instances of instantiation are ad-hoc and strategic, but useful in practice for permitting programs such as:

function id<T>(x: T): T
	return x
end

local idNum : (number) -> number
idNum = id -- ok

However, they have also been a source of some typechecking bugs because of how they actually make a determination as to whether the instantation should happen, and they currently open up some potential soundness holes when instantiating functions in table types since properties of tables are mutable and thus need to be invariant (which the automatic-instantiation potentially masks).

Design

The goal then is to rework subtyping to support the relationship we want in the first place: allowing polymorphic functions to be used where instantiated functions are expected. In particular, this means adding instantiation itself to the subtyping relation. Formally, that’d look something like:

instantiate(<a>(T1) -> T2) = (T1') -> T2'
(T1') -> T2' <: (T3) -> T4
--------------------------------------------
<a>(T1) -> T2 <: (T3) -> T4)

Or informally, we’d say that a generic function type is a subtype of another function type if we can instantiate it and show that instantiated function type to be a subtype of the original function type. Implementation-wise, this loose formal rule suggests a strategy of when we’ll want to apply instantiation. Namely, whenever the subtype and supertype are both functions with the potential subtype having some generic parameters and the supertype having none. So, if we look once again at our simple example from motivation, we can walk through how we expect it to type check:

function id<T>(x: T): T
	return x
end

local idNum : (number) -> number
idNum = id -- ok

First, id is given the type <T>(T) -> T and idNum is given the type (number) -> number. When we actually perform the assignment, we must show that the type of the right-hand side is compatible with the type of the left-hand side according to subtyping. That is, we’ll ask if <T>(T) -> T is a subtype of (number) -> number which matches the rule to apply instantiation since the would-be subtype has a generic parameter while the would-be supertype has no generic parameters. This contrasts with the current implementation which, before asking the subtyping question, checks if the type of the right-hand side contains any generics at any point and if the type of the left-hand side cannot possibly contain generics and instantiates the right-hand side if so.

Adding instantiation to subtyping does pose some additional questions still about when exactly to instantiate. Namely, we need to consider cases like function application. We can see why by looking at some examples:

function rank2(f: <a>(a) -> a): (number) -> number
    return f
end

In this case, we expect to allow the instantiation of f from <a>(a) -> a to (number) -> number. After all, we can consider other cases like where the body instead applies f to some particular value, e.g. f(42), and we’d want the instantiation to be allowed there. However, this means we’d potentially run into issues if we allowed call sites to rank2 to pass in non-polymorphic functions. A naive approach to implementing this proposal would do exactly that because we currently treat contravariant subtyping positions (i.e. for the arguments of functions) as being the same as our normal (i.e. covariant) subtyping relation but with the arguments reversed. So, to type check an application like rank2(function(str: string) return str + "s" end) (where the function argument is of type (string) -> string), we would ask if <a>(a) -> a is a subtype of (string) -> string. This is precisely the question we asked in the original example, but in the contravariant context, this is actually unsound since rank2 would then function as a general coercion from, e.g., (string) -> string to (number) -> number.

This sort of behavior does come up in other languages that mix polymorphism and subtyping. If we consider the same example in F#, we can compare its behavior:

let ranktwo (f : 'a -> 'a) : int -> int = f
let pluralize (s : string) : string = s + "s"
let x = ranktwo pluralize

For this example, F# produces one warning and one error. The warning is applied to the function definition of ranktwo itself (coded FS0064), and says “This construct causes code to be less generic than indicated by the type annotations. The type variable ‘a has been constrained to be type ‘int’.” This warning highlights the actual difference between our example in Luau and the F# translation. In F#, 'a is really a free type variable, rather than a generic type parameter of the function ranktwo, as such, this code actually constrains the type of ranktwo to be (int -> int) -> (int -> int). As such, the application on line 3 errors because our (string -> string) function is simply not compatible with that type. With higher-rank polymorphic function parameters, it doesn’t make sense to warn on their instantiation (as illustrated by the example of actually applying f to some particular data in the definition of rank2), but it’s still just as problematic if we were to accept instantiated functions at polymorphic types. Thus, it’s important that we actually ensure that we only instantiate in covariant contexts. So, we must ensure that subtyping only instantiates in covariant contexts.

It may also be helpful to consider an example of rank-1 polymorphism to understand the full scope of the behavior. So, we can look at what happens if we simply move the type parameter out in our working example:

function rank1<a>(f: (a) -> a): (number) -> number
    return f
end

In this case, we expect an error to occur because the type of f depends on what we instantiate rank1 with. If we allowed this, it would naturally be unsound because we could again provide a (string) -> string argument (by instantiating a with string). This reinforces the idea that the presence of the generic type parameter is likely to be a good option for determining instantiation (at least when compared to the presence of free type variables).

Drawbacks

One of the aims of this proposal is to provide a clear and predictable mental model of when instantiation will take place in Luau. The author feels this proposal is step forward compared to the existing ad-hoc usage of instantiation in the typechecker, but it’s possible that programmers are already comfortable with the mental model they have built for the existing implementation. Hopefully, this is mitigated by the fact that the new setup should allow all of the sound uses of instantiation permitted by the existing system. Notably, however, programmers may be surprised by the added restriction when it comes to properties in tables. In particular, we can consider a small variation of our original example with identity functions:

function id<T>(x: T): T
	return x
end

local poly : { id : <a>(a) -> a } = { id = id }

local mono : { id : (number) -> number }
mono = poly -- error!
mono.id = id -- also an error!

In this case, the fact that we’re dealing with a property of a table type means that we’re in a context that needs to be invariant (i.e. not allow subtyping) to avoid unsoundness caused by interactions between mutable references and polymorphism (see things like the value restriction in OCaml to understand why). In most cases, we believe programmers will be using functions in tables as an implementation of methods for objects, so we don’t anticipate that they’ll actually want to do the unsound thing here. The accepted RFC for read-only properties gives us a technically-precise solution since read-only properties would be free to be typechecked as a covariant context (since they disallow mutation), and thus if the property id was marked read-only, we’d be able to do both of the assignments in the above example.

Alternatives

The main alternatives would likely be keeping the existing solution (and likely having to tactically fix future bugs where instantiation either happens too much or not enough), or removing automatic instantiation altogether in favor of manual instantiation syntax. The former solution (changing nothing) is cheap now (both in terms of runtime performance and also development cost), but the existing implementation involves extra walks of both types to make a decision about whether or not to perform instantiation. To minimize the performance impact, the functions that perform these questions (isGeneric and maybeGeneric) actually do not perform a full walk, and instead try to strategically look at only enough to make the decision. We already found and fixed one bug that was caused by these functions being too imprecise against their spec, but fleshing them out entirely could potentially be a noticeable performance regression since the decision to potentially instantiate is one that comes up often.

Removing automatic instantiation altogether, by contrast, will definitely be “correct” in that we’ll never instantiate in the wrong spot and programmers will always have the ability to instantiate, but it would be a marked regression on developer experience since it would increase the annotation burden considerably and generally runs counter to the overall design strategy of Luau (which focuses heavily on type inference). It would also require us to actually pick a syntax for manual instantiation (which we are still open to do in the future if we maintain an automatic instantiation solution) which is frought with parser ambiguity issues or requires the introduction of a sigil like Rust’s turbofish for instantiation. Discussion of that syntax is present in the generic functions RFC.