RTN aliases as a simpler alternative to TAITs / existential types

I have been reading the various RFCs/comments on existential types and TAITs and trying it out a bit on nightly. To be honest even though the feature is undoubtedly powerful, I'm not super fond of it due to the type inference complexity where the actual type is determined over modules or maybe even the whole crate.

It feels like TAITs are going to create an implicit, hard-to-follow but at the same time fairly tight relationship between the declaration site and the definition site.

At the same time, proposal is being made to have the ability to name existential return types and it seems like this will be needed anyway in order to be able to express Send bounds and the like.

It occurs to me having RTN aliases instead of TAITs would achieve more or less the same (?) without having to go through all the complexity of defining the right inference rules and with much improved clarity of the code - I could just follow from the alias declaration site directly to where the type is defined (I could navigate there using an IDE easily). By "RTN aliases" I mean roughly something like this, using syntax from Niko's blogpost:

pub type AnAlias = bar();

fn bar() -> impl Iterator {
   // ...
}

pub type AnotherAlias<T> = baz::<T>(..);

fn bar<T>(x: T) -> impl Iterator<Item = T> {
   // ...
}

I guess the crucial question here is whether TAITs / existential types can do things that an RTN alias couldn't and whether that extra power justifies the added complexity and ergonomics burden.

It seems that most of the time TAITs would be used to spec a return type of a specific async- or closure-featuring function. One thing that comes to mind is lifetime/types capture specs, ie. opting out of the default capture-everything behaviour, but it seems this could be solved with the -> impl<'a, T> syntax without putting distance between the function and the spot where these rules are defined - which is also an uncomfortable feature of TAITs.

Wdyt? Thanks!

Maybe you should give an example of what "RTN aliases" are or at least expand the acronym.

1 Like

@pitaj Good point, I added a tentative example.

I suppose an example with customized lifetime captures might looks something like this:

pub type WithLifetime<'a, T> = for<'b> qux::<'a, 'b, T>(..);
    // or maybe just use '_? not sure...

fn qux<'a, 'b, T>(x: &'a T, y: &'b str) -> impl<'a, T> Iterator<Item = T> {
   // ...
}

RTN = Return Type Notation.

RTN “works” because Type::method() is (effectively) a novel syntax. (It isn't due to the Fn traits, but effectively nobody (outside hyper defensively written macros) uses the fact that the Fn traits are normal namespaced items and just relies on them being in the prelude as pseudo builtin syntax.)

Just func() in type position isn't novel, due to the Fn traits. In fact, if I define fn Fn(), then Fn() would be ambiguous between RTN and edition2015 dyn Fn(). Maybe the old edition ambiguity is acceptable, but it isn't ideal (code which compiles in an old edition should either work the same or be an error in the latest edition; this is the case for associated RTN so long as std doesn't define Fn in the value namespace of std::ops).

We have typeof reserved as a hard keyword. It's desirable to be able to name the type of fn items anyway, so why not spell free function RTN with that, e.g. <typeof qux<'a, 'b, T>>::Output? RTN is then sugar that applies for method items only. (Methods are already sugared compared to free functions with .call notation.) Making path::Item::name() a Fn() style trait path if Item is a mod, RTN if Item is a type.

Going back a step, though, imo the TAIT machinery should exist. Yes, maybe it makes more sense in many cases to say type R = <typeof F>::Output; fn f() -> impl Trait; instead of type R = impl Trait; fn() -> R;. (If there's only a single such “defining use” which all others bottom out to, it certainly seems reasonable to say so, especially since rustdoc shows the “effective type” inline for type aliases now.) However, the latter form is still a very natural refactoring path for “I want to name the output type,” so even if it's an error, the error should tell you the type (projection) to substitute in if it's inferable. (C.f. the error for fn f() -> _ items, which tells you the proper type if it's inferable from the fn body.)

At the point where the type is inferable, though, why not just permit writing TAIT? It doesn't introduce any new globally relevant type inference (since it could be rewritten as RTN), and it makes better local type inference possible (when there's more than one defining use, they must all resolve to the same underlying type; this is sometimes desirable e.g. with group of mutually recursive functions where none are the singular base case[1]). It's entirely arguable and likely context dependent (e.g. if the type alias has meaningful documentation) whether having RTN or TAIT as the “more canonical” spelling of the (opaque) type (alias) is preferable.

The presence of RTN also suggests the possibility of fn f() -> f(), although that's likely absurd enough to just forbid (i.e. as a recursive type definition) and suggest impl Trait instead. (what would it be, otherwise, impl Destruct?)


  1. If they do all resolve to the same type, pub type R = <typeof private_helper>::Output would work, but that's horrible UX since the type is now completely opaque. Though maybe #[doc(inline)] could be made clever enough to inline concrete type projections to mitigate that? Private type aliases are already transparently resolved for public docs, even without #[doc(inline)], after all, so could the same be done for associated type projections off of (concretely resolvable) private items? ↩︎

1 Like

:+1:

Hmm, not sure about this, the error only needs to say the impl Trait from the would-be TAIT, no? Rather than the actual inferred type...

Ok, I found a counterargument to my proposal, essentially something like the following:

type Foo = impl Trait;

#[cfg(unix)]
mod impl_unix {
    fn foo() -> Foo { /* ... */ }
}

#[cfg(windows)]
mod impl_windows {
    fn foo() -> Foo { /* ... */ }
}

In this situation it would not be practical to name the function in the alias.

Sorry Internals forum I guess I've had you be my Mr Wilson :laughing:

Maybe this has been covered already; I've not kept up-to-date with TAIT, but if the worry is about Foo being decided by its usage sites (I presume they all need to agree), this spelling seems fine. If Foo is re-decided at each usage site, wouldn't that ask for something like:

// type Foo = impl Trait;
// trait Foo = Trait;
// type impl Foo = impl Trait;

#[cfg(unix)]
mod impl_unix {
    fn foo() -> impl Foo { /* ... */ }
}

#[cfg(windows)]
mod impl_windows {
    fn foo() -> impl Foo { /* ... */ }
}

Apologies if this has been covered already, but this brought the topic higher in my awareness: