Halfway-exported "pub" types in public interface

Consider this structure (which I've seen in the wild in some crates):

mod foo { // pretend this is the actual crate lib
    mod bar { // pretend this is a module inside the crate
        #[derive(Debug)]
        pub struct Bar;
    }

    use bar::Bar;

    pub fn foo(bar: Bar) { ... }
}

(playground (with some functions to avoid the unrelated warnings))

foo here is public - but unusable. Outside foo (which represents a crate) Bar is unnameable because it is not exported, so foo cannot be called. If Bar was declared without pub at the top level this thing would have been an error - but because of this "trick" of putting it inside a module it does not ever generate a Clippy warning.

I think this should at least be a compiler warning.

(I see this was discussed in the past, but I figure after 7 years it should be acceptable to bring it up again)

If you had another pub function make_bar() -> Bar it would be “fine” (in the sense that it would be possible to call foo()). So either the proposal is to do a module-wide check for the existence of exposed Bar instances (in line with similar checks like dead_code, but a bit more work), or it’s that you shouldn’t be able to put unnameable types in type signatures regardless of whether they’re constructible (a valid stance to take, but new).

2 Likes

Sealed types are less common than sealed traits, but not unheard of.[1]


  1. mod private ↩︎

3 Likes

There's now a lint for this, but it is not enabled by default.

1 Like

I'm making that stance. I think it should be considered and anti-pattern for the following reason.

  1. It's not locally identifiable - you cannot determine that this is the case just from looking at the method and/or the type. You need to scan the entire crate and see if there is any path that exports the type (maybe it gets re-exported from an exported module?)
  2. A consequence of the first point is that it becomes too easy to do this accidentally. So easy, unless clearly documented (either with comments or by the names of the variables/functions/types involved) it's better to assume that this is an oversight rather than a technique to control the type's construction.
  3. The unnameable types are still part of the crate's API, and users can call their pub methods and access their pub fields - but rustdoc will not generate (by default) their documentation.
  4. It puts constraints on the user behind just "you can't construct that type yourself". For example - it prevents you from writing your own function that accepts a Bar (which you'd get from a make_bar) and passes it to foo. It is not a library's business to limit the architecture of its users in such ways.
  5. The line of what gets limited and what doesn't is very jaggy. You can't write functions that get a Bar, but you can write closures. Or actually functions can get a Bar if they do it via generic parameters. You can put it in a (non-generic) struct but you can put it in a tuple. Unless you are trying to name that tuple. All these rules make sense from the point of Rust's type system, but not from the higher abstraction any library will want to represent.
  6. Even if you find a usecase that requires imposing exactly these limitations - there is a risk that improvements to type inference, ergonomics, or Rust's type system in general will enable things the crate was trying to disallow. Associated type inference, for example, would provide a way to name the type in function signatures and struct fields - which you were supposedly trying to prevent. Constructor inference would circumvent the entire thing.

If you want to make sure users cannot construct the type itself, just add a zero sized private field to it.

2 Likes

private-in-public apis like this are actually fairly common in golang. it's an odd feature, but it's definatly a feature and not a bug.

1 Like

can't you just mark it #[non_exhaustive] to get the same effect?

Correct. Forgot about this. But either way is better than making it unnameable.