Summary

Allow type aliases and associated types to use impl Trait, replacing the prototype existential type as a way to declare type aliases and associated types for opaque, uniquely inferred types.

Motivation

RFC 2071 described a method to define opaque types satisfying certain bounds (described in RFC 2071 and elsewhere as existential types). It left open the question of what the precise concrete syntax for the feature should be, opting to use a placeholder syntax, existential type. Since then, a clearer picture has emerged as to how to rephrase impl Trait in terms of type inference, rather than existentially-quantified types, which also provides new motivation for a proposed concrete syntax making use of the existing and familiar syntax impl Trait.

In essence, this RFC proposes that the syntax:

type Foo = impl Bar;

be implemented with the same semantics as:

existential type Foo: Bar;

both as the syntax for type aliases and also for associated types, and that existing placeholder be removed.

Furthermore, this RFC proposes a strategy by which the terminology surrounding impl Trait might be transitioned from existentially-type theoretic terminology to type inference terminology, reducing the cognitive complexity of the feature.

Semantic Justification

Currently, each occurrence impl Trait serves two complementary functional purposes.

  1. It defines an opaque type T (that is, a new type whose precise identification is hidden) satisfying (trait) bounds.
  2. It infers the precise type for T (that must satisfy the bounds for T), based on its occurrences.

Thus, the following code:

fn foo() -> impl Bar {
    // return some type implementing `Bar`
}

is functionally equivalent to:

struct __foo_return(/* some inferred type (2) */); // (1)

fn foo() -> __foo_return {
    // return some type implementing `Bar` wrapped in `__foo_return` (3)
}

The generated type __foo_return is not exposed: it is automatically constructed from any valid type (as in (3)).

Note that, in order for the type inference to support argument-position impl Trait, which may be polymorphic (just like a generic parameter), the inference used here is actually a more expressive form of type inference similar to ML-style let polymorphism. Here, the inference of function types may result in additional generic parameters, specifically relating to the occurrences of argument-position impl Trait.

RFC 2071 proposed a new construct for declaring types acting like impl Trait, but whose actual type was not hidden (i.e. a method to expose the __foo_return above), to use such types in positions other than function arguments and return-types (for example, at the module level).

If the semantics of impl Trait are justified from the perspective of existentially-quantified types, this new construct is a sensible solution as re-using impl Trait for this purpose introduces additional inconsistency with the existential quantifier scopes. (See here for more details on this point.)

However, if we justify the semantics of impl Trait solely using type inference (as in point 2 above, expounded below) then we can re-use impl Trait for the purpose of existential type consistently, leading to a more unified syntax and lower cognitive barrier to learning.

Here, we define the syntax:

type Foo = impl Bar;

to represent a type alias to a generated type:

struct __Foo_alias(/* some inferred type */);
type Foo = __Foo_alias;

This is functionally identical to existential type, but remains consistent with impl Trait where the original generated type is technically still hidden (exposed through the type alias).

Aliasing impl Trait in function signatures

Note that though the type alias above is not contextual, it can be used to alias any existing occurrence of impl Trait in return position, because the type it aliases is inferred.

fn foo() -> impl Bar {
    // return some type implementing `Bar`
}

can be replaced by:

type Baz = impl Bar;

fn foo() -> Baz {
    // return some type implementing `Bar`
}

However, if the function is parameterised, it may be necessary to add explicit parameters to the type alias (due to the return-type being within the scope of the function’s generic parameters, unlike the type alias).

Using Baz in multiple locations constrains all occurrences of the inferred type to be the same, just as with existential type.

Notice that we can describe the type alias syntax using features that are already present in Rust, rather than introducing any new constructs.

Learnability Justification

Reduced technical and theoretic complexity

As a relatively recently stabilised feature, there is not significant (official) documentation on impl Trait so far. Apart from the various RFC threads and internal discussions, impl Trait is described in a blog post and in the Rust 2018 edition guide. The edition guide primary describes impl Trait intuitively, in terms of use cases. It does however contain the following:

impl Trait in argument position are universal (universally quantified types). Meanwhile, impl Trait in return position are existentials (existentially quantified types).

This is incorrect (albeit subtly): in fact, the distinction between argument-position and return-position impl Trait is the scope of their existential quantifier. This (understandable) mistake is pervasive and it’s not alone (the fact that those documenting the feature missed this is indicative of the issues surrounding this mental model). The problem stems from a poor understanding of what “existential types” are — which is entirely unsurprising: existential types are a technical type theoretic concept that are not widely encountered outside type theory (unlike universally-quantified types, for instance). In discussions about existential types in Rust, these sorts of confusions are endemic.

In any model that does not unify the meaning of impl Trait in various positions, these technical explanations are likely to arise, as they provide the original motivation for treating impl Trait nonhomogeneously. From this perspective, it is valuable from documentation and explanatory angles to unify the uses of impl Trait so that these types of questions never even arise. Then we would have the ability to transition entirely away from the topic of existentially-quantified types.

Natural syntax

Having explained impl Trait solely in terms of type inference (or less formal equivalent explanations), the syntax proposed here is the only natural syntax. Indeed, while discussing the syntax here, many express surprise that this syntax has ever been under question (often from people who think of impl Trait from an intuition about the feature’s behaviour, rather than thinking about the existential type perspective).

The argument that is occasionally put forward: that this syntax makes type aliases (or their uses) somehow contextual, is also addressed by the above interpretation. Indeed, every use of an individual impl Trait type alias refers to the same type. This argument is detailed and addressed further in Drawbacks.

The following section provides a documentation-style introductory explanation for impl Trait that justifies the type alias syntax proposed here.

Guide-level explanation

[Adapted from the Rust 2018 edition guide.]

impl Trait provides a way to specify unnamed concrete types with specific bounds. You can currently use it in three places (to be extended in future versions of Rust: see the tracking issue for more details):

  • Argument position
  • Return position
  • Type aliases
trait Trait {}

// Argument-position
fn foo(arg: impl Trait) {
    // ...
}

// Return-position
fn bar() -> impl Trait {
    // ...
}

// Type alias
type Baz = impl Trait;

How does impl Trait work?

Whenever you write impl Trait, in any of the three places, you’re saying that you have some type that implements Trait, but you don’t want to expose any more information than that. The concrete type that implements Trait will be hidden, but you’ll still be able to treat the type as if it implements Trait: calling trait methods and so on.

The compiler will infer the concrete type, but other code won’t be able to make use of that fact. This is straightforward to describe, but it manifests a little differently depending on the place it’s used, so let’s take a look at some examples.

Argument-position

trait Trait {}

fn foo(arg: impl Trait) {
    // ...
}

Here, we’re saying that foo takes an argument whose type implements Trait, but we’re not saying exactly what it is. Thus, the caller can pass a value of any type, as long as it implements Trait.

You may notice this sounds very like a generic type parameter. In fact, functionally, using impl Trait in argument position is almost identical to a generic type parameter.

fn foo(arg: impl Trait) {
    // ...
}

// is almost the same as:

fn foo<T: Trait>(arg: T) {
    // ...
}

The only difference is that you can’t use turbo-fish syntax for the first definition (as turbo-fish syntax only works with explicit generic type parameters). Thus, it’s worth being mindful that switching between impl Trait and generic type parameters can consistute a breaking change for users of your code.

Return-position

trait Trait {}

impl Trait for i32 {}

fn bar() -> impl Trait {
    5
}

Using impl Trait as a return type is more useful, as it enables us to do things we weren’t able to before. In this example, bar returns some type that’s not specified: it just asserts that the type implements Trait. Inside the function, we can return any type that fits, but from the caller’s perspective, all they know is that the type implements the trait.

This is useful especially for two things:

  • Hiding (potentially complex) implementation details
  • Referring to types that were previously unnameable, such as closures

[Here, we would also provide a more useful example, as in the Rust 2018 edition guide.]

Type alias

trait Trait {}

type Baz = impl Trait;

impl Trait type aliases are useful for declaring types that are constrained by traits, but whose concrete type should be a hidden implementation detail. We can use it in place of return-position impl Trait as in the previous examples.

trait Trait {}

type Baz = impl Trait;

// The same as `fn bar() -> impl Baz`
fn bar() -> Baz {
    // ...
}

However, if we use Baz in multiple locations, we constrain the concrete type referred to by Baz to be the same, so we get a type that we know will be the same everywhere and will satisfy specific bounds, whose concrete type is hidden. This can be useful in libraries where you want to hide implementation details.

trait Trait {}

type Baz = impl Trait;

impl Trait for u8 {}

fn foo() -> Baz {
    let x: u8;
    // ...
    x
}

fn bar(x: Baz, y: Baz) {
    // ...
}

struct Foo {
    a: Baz,
    b: (Baz, Baz),
}

In this example, the concrete type referred to by Baz is guaranteed to be the same wherever Baz occurs.

Note that using Baz as an argument type is not the same as argument-position impl Trait, as Baz refers to a unique type, whereas the concrete type for argument-position impl Trait is determined by the caller.

trait Trait {}

type Baz = impl Trait;

fn foo(x: Baz) {
    // ...
}

// is *not* the same as:

fn foo(x: impl Trait) {
    // ...
}

Just like with any other type alias, we can use impl Trait to specify associated types for traits, as in the following example.

trait Trait {
    type Assoc;
}

struct Foo {}

impl Trait for Foo {
    type Assoc = impl Debug;
}

Here, anything that makes use of Foo knows that Foo::Assoc implements Debug, but has no knowledge of its concrete type.

[Eventually, we would also describe the use of impl Trait in let, const and static bindings, but as they are as-yet unimplemented and function the same as return-type impl Trait, they haven’t been included here.]

Reference-level explanation

Since RFC 2071 was accepted, the initial implementation of existential type has already been completed. This RFC would replace the syntax of existential type, from:

existential type Foo: Bar;

to:

type Foo = impl Bar;

In addition, having multiple occurrences of impl Trait in a type alias or associated type is now permitted, where each occurrence is desugared into a separate inferred type. For example, the following alias:

type Foo = Arc<impl Iterator<Item = impl Debug>>;

would be desugared to the equivalent of:

existential type _0: Debug;
existential type _1: Iterator<Item = _0>;
type Foo = Arc<_1>;

Furthermore, when documenting impl Trait, explanations of the feature would avoid type theoretic terminology (specifically “existential types”) and prefer type inference language (if any technical description is needed at all).

impl Trait type aliases may contain generic parameters just like any other type alias. The type alias must contain the same type parameters as its concrete type, except those implicitly captured in the scope (see RFC 2071 for details).

// `impl Trait` type aliases may contain type parameters...
#[derive(Debug)]
struct DebugWrapper<T: Debug>(T);

type Foo<T> = impl Debug;

fn get_foo<T: Debug>(x: T) -> Foo<T> { DebugWrapper(x) }

// ...and lifetime parameters (and so on).
#[derive(Debug)]
struct UnitRefWrapper<'a>(&'a ());

type Bar<'a> = impl Debug;

fn get_bar<'a>(y: &'a ()) -> Bar<'a> { UnitRefWrapper(y) }

Drawbacks

This feature has already been accepted under a placeholder syntax, so the only reason not to do this is if another syntax is chosen as a better choice, from an ergonomic and consistency perspective.

There is one critique of the type alias syntax proposed here, which is frequently brought up in discussions, regarding referential transparency.

Consider the following code:

fn foo() -> impl Trait { /* ... */ }
fn bar() -> impl Trait { /* ... */ }

A user who has not come across impl Trait before might imagine that the return type of both functions is the same (as synactically, they are). However, because each occurrence of impl Trait defines a new type, the return types are potentially distinct.

This is a problem inherent with impl Trait (and any other syntax that determines a type contextually) and thus impl Trait type aliases have the same caveat.

A user unaware of the behaviour of impl Trait might try refactoring this example into the following:

type SharedImplTrait = impl Trait;

fn foo() -> SharedImplTrait { /* ... */ }
fn bar() -> SharedImplTrait { /* ... */ }

This evidently means something different to what the user intended, because here SharedImplTrait is inferred as a single type, shared with foo and bar.

However, this problem is specifically with the behaviour of impl Trait and not with the type aliases, whose behaviour is not altered. Specifically note that, after this RFC, it is still true that for any type alias:

type Alias = /* ... */;

all uses of Alias refer to the same unique type. The potential confusion is rather with whether all uses of impl Trait refer to the same unique type (which is, of course, false).

It is likely that a misunderstanding of the nature of impl Trait in argument or return position will lead to similar confusion as to the role of impl Trait in type aliases, and vice versa. By clearly teaching the behaviour of impl Trait, we should be able to eliminate most of these conceptual difficulties.

Since we will teach impl Trait cohesively (that is, argument-position, return-position and type alias impl Trait at the same time), it is unlikely that users who understand impl Trait will be confused about impl Trait type aliases. (What’s more, examples in the reference will illustrate this clearly.)

Rationale and alternatives

The justification for the type alias syntax proposed here comes down to two key motvations:

  • Consistency
  • Minimality

Ideally a language should provide as small a surface area as possible. New keywords or constructs add to the cognitive complexity of a language, requiring users to look more concepts up or read larger guides to understand code they read and want to write. If it is possible to add new capabilities to the language that fit into the existing syntax and concepts, this generally increases cohesion.

The syntax proposed here is a natural extension of the existing impl Trait syntax and it is felt that, should users encounter it after seeing argument-position and return-position impl Trait, its meaning will be immediately clear. On the other hand, new keywords or syntax will require the user to investigate further and provide more questions:

  • “Why can’t I use impl Trait here?”
  • “What’s the difference between impl Trait and X?”

Using different syntax, and then trying to justify the differences between impl Trait and some new feature, seems likely to lead into conversations about existential types, which are almost always unhelpful for understanding.

type Foo = impl Bar; has the additional benefit that it’s easy to search for and can appear alongside documentation for other uses of impl Trait.

The syntax existential type was intended to be a placeholder, so we need to pick a syntax eventually for this feature. Justification for why this is the best syntax, given the existing syntax in Rust, has been included throughout the RFC.

The other alternatives commonly given are:

  • type Foo: Bar;, which suffers from complete and confusing inconsistency with associated types. Although on the surface, they can appear similar to existential types, by virtue of being a declaration that “some type exists [that will be provided]”, they are more closely related to type parameters (which also declare that “some type exists that will be provided”), though type parameters with Haskell-style functional dependencies. This is sure to lead to confusions as users wonder why two features with identical syntax turn out to behave so differently.
  • Some other, new syntax for declaring a new type that acts in the same way as existential type. Though a new syntax would not be inconsistent, it would not be minimal, given that we can achieve the functionality using existing syntax (impl Trait). What’s more, if the syntax proposed here were not added alongside this new syntax, this would lead to inconsistencies with impl Trait.

Unresolved questions

None