Skip to content

type context (bidirectional checking)Β #168

@carljm

Description

@carljm

Currently red-knot does "inside-out" type inference. The type of an expression is inferred by inferring types for all sub-expressions, bottom-up, and using those to infer a type for the outer expression. A given expression's type is independent of the expression or statement it is part of; it depends only on the expression itself (and of course on name resolution.)

As we implement more complex type system features, such as generics, we will likely find that in some cases we need to go "outside-in", using the outer type context, or expected type, to guide type inference of an expression. In the literature, this is called "bidirectional type inference", where the inside-out inference is often called infer and the outside-in one called check. (Here's a good short summary.)

Here's one example case where we might use type context with generics:

def f[T](x: T) -> list[T]:
    return [x]

v: list[int] = f(True)

If we did purely inside-out inference, we would likely infer the type of the f(True) call as list[Literal[True]], or maybe list[bool] if we widen literal types, and then issue an error when we try to assign it to list[int] (list[T] is invariant in T, so list[bool] is not assignable to list[int], even though bool is a subtype of int.)

But type context can help us in this case to decide to instead widen the inferred type of the generic function call to list[int] and thus permit the assignment. The type context allows us to pick "correctly" among the various more-or-less-precise (but all correct) inferred types for f(True).

Another example is this one, with TypedDict:

from typing import TypedDict

class TD(TypedDict):
    x: int

v1: TD = {"x": 1}
v2: dict[str, int] = {"x": 1}

Both of these assignments should be valid, but if we were to infer the RHS purely inside-out, there are a variety of possible types we might legitimately infer for it. Type context can help us decide that we should infer the type TD (or at least something assignable to it) in the first case, and we should infer dict[str, int] in the second case.

I'm not sure that we strictly need type context for the latter case; I think in some cases sufficiently precise types can avoid the need. For instance, we could infer the dictionary literal {"x": 1} as a dedicated immutable dict-literal type (internal-only, not representable in annotations), with knowledge of precisely which keys it has and their types, which could then soundly be assignable to both dict[str, int] and TD. But it is probably not feasible to add such precise types for all scenarios where we would otherwise need bidirectional checking.

The first scenario, with invariant generics, is more difficult to handle well without bidirectional checking. Even if we infer the expression [x] as having type list[Unknown | T] (since it isn't annotated), this would then be assignable to list[T] (thanks to gradual typing), and we would still end up with a return type of e.g. list[Literal[True]] or list[bool], which would not be assignable to list[int].

Bidirectional checking does introduce a lot of questions about how far type context can "stretch", and if it can operate retroactively. For example:

def f[T](x: T) -> list[T]:
    return [x]

v = f(True)

def g(x: list[int]): ...

g(v)

With v un-annotated, does the fact that it is later passed to g, which expects list[int], affect our earlier inference of f(True)? (I think we probably don't want to go there.)

Cases like this become a lot nicer if we could avoid bidirectional checking and instead find a way to represent the precise type of f(True) that is assignable to all of list[int], list[bool], etc. But this seems difficult or impossible to achieve with invariant generics.

This issue is a place to collect examples of where we will need bidirectional inference, and discuss how to approach it.

Sub-issues

Metadata

Metadata

Assignees

Labels

bidirectional inferenceInference of types that takes into account the context of a declared type or expected type

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions