Warn When Member Access Level Exceeds Enclosing Type

Introduction

It’d be nice to have compiler warnings and errors for redundant access control modifiers when a member's declared access level exceeds its enclosing type's effective access level. In Swift's access control model, a member cannot be more accessible than its enclosing type, making such declarations misleading and potentially confusing. This proposal aims to improve code clarity and help developers better understand the actual accessibility of their APIs.

Motivation

Swift's access control system follows a fundamental rule: no entity can be more accessible than its enclosing context. For example, a public property of an internal struct is effectively internal, regardless of the explicit public modifier. However, the compiler currently accepts such declarations without any warnings, leading to several problems:

1. Misleading Code and False Expectations

Consider this example:

struct User: Decodable {
    struct Name: Decodable {
        let title: String
        let first: String
        let last: String
    }
    
    public let name: Name
    let email: String

    public func foo() {
        print("Hello World!")
    }
}

Here, the User struct has the default internal access level. The public modifiers on name and foo() suggest these members are publicly accessible, but they are effectively internal. This creates false expectations for developers reading or maintaining the code.

2. Code Review Confusion

When reviewing pull requests, team members might assume that marking a member as public makes it part of the public API. They may spend time discussing API design implications for members that aren't actually publicly accessible.

3. Maintenance Burden

As codebases evolve, developers might change an outer type's access level without realizing that inner members have redundant modifiers. This creates visual noise and makes it harder to understand the true access boundaries of a module.

4. Learning Curve for New Swift Developers

Newcomers to Swift might not understand why their public member isn't accessible from another module, leading to confusion about how access control works. A compiler diagnostic would provide immediate feedback and education about Swift's access control rules.

5. API Evolution Pitfalls

When developers later decide to make an internal type public, they might overlook members that already have public modifiers, assuming they're already correctly configured. This can lead to unintended API surface exposure.

Currently, developers must rely on manual code review, third-party linters, or trial-and-error to catch these issues. The compiler has all the information needed to detect these redundant modifiers but provides no feedback.

Proposed Solution

It’d nice to add compiler diagnostics when a member's explicitly declared access level exceeds its enclosing type's effective access level. The diagnostic should:

1. Emit a Warning (Initially)

For Swift 6.x compatibility, start with a warning:

internal struct User {
    public let name: Name  // Warning: 'public' modifier is redundant; 
                          // 'name' is effectively 'internal' because 
                          // its enclosing type 'User' is 'internal'
    
    public func foo() {}   // Warning: 'public' modifier is redundant; 
                          // 'foo()' is effectively 'internal' because 
                          // its enclosing type 'User' is 'internal'
}

2. Provide Fix-It Suggestions

The compiler should offer automatic fix-its:

  • Option 1: Remove the redundant modifier (most common case)
  • Option 2: Suggest making the enclosing type match the intended access level

3. Handle Nested Types

The diagnostic should work correctly with nested types at any depth:

internal class Outer {
    public class Middle {    // Warning: redundant 'public'
        public func bar() {} // Warning: redundant 'public' (limited by Outer)
    }
}

4. Consider File-Private and Private Edge Cases

The diagnostic should be intelligent about fileprivate and private:

fileprivate struct Config {
    internal let value: String  // Warning: redundant 'internal'
    public let name: String     // Warning: redundant 'public'
}

5. Future Path to Error

In a future Swift version (e.g., Swift 7), this could become a compile-time error, similar to how other redundant or contradictory declarations are handled.

Example of Improved Code:

Before (with redundant modifiers):

struct User: Decodable {
    public let name: Name
    public func foo() {
        print("Hello World!")
    }
}

After (clean and accurate):

struct User: Decodable {
    let name: Name
    func foo() {
        print("Hello World!")
    }
}

Or, if public access was truly intended:

public struct User: Decodable {
    public let name: Name
    public func foo() {
        print("Hello World!")
    }
}

This change would make Swift's access control more transparent, improve code quality, and provide better learning experiences for developers at all skill levels.

4 Likes

A scenario where this could be a problem is where a type's visibility is platform-specific. Consider (hypothetically:)

#if os(macOS)
public class NSHDMIOutputStream { ... }
#else
internal class NSHDMIOutputStream { ... }
#endif

extension NSHDMIOutputStream: CustomStringConvertible {
  public var description: String { "HDMI" }
}

I wouldn't want to ask coders to reimplement extensions on such types just to suppress these warnings. Would there be an in-source way to opt out of them for scenarios like this one?

2 Likes

Great point! This is a valid edge case that deserves consideration.

For scenarios with platform-specific access levels, I see a few potential approaches:

1. Match the Conditional Compilation (Recommended)

The clearest solution would be to mirror the conditional compilation in the extension:

#if os(macOS)
public class NSHDMIOutputStream { ... }
#else
internal class NSHDMIOutputStream { ... }
#endif

extension NSHDMIOutputStream: CustomStringConvertible {
  #if os(macOS)
  public var description: String { "HDMI" }
  #else
  var description: String { "HDMI" }  // or 'internal var'
  #endif
}

While this adds some verbosity, it makes the actual access level explicit and platform-aware, which arguably improves clarity rather than hiding the platform difference.

2. Compiler Intelligence for Conditional Access Levels

The compiler could detect when an enclosing type has platform-specific access levels and either:

  • Not emit warnings in those cases, or
  • Emit a more nuanced warning: "Note: 'public' is redundant on non-macOS platforms where 'NSHDMIOutputStream' is 'internal'"

This would require tracking whether a type's access level varies across build configurations.

3. Warning Suppression Mechanism

Swift could introduce targeted warning suppression (similar to other languages):

@_suppressAccessLevelWarnings
extension NSHDMIOutputStream: CustomStringConvertible {
  public var description: String { "HDMI" }
}

However, Swift generally avoids per-warning suppression attributes in favor of fixing the underlying issue.

My Take

I think option 2 (compiler intelligence) would be the ideal solution—detect when access levels are conditional and adjust the diagnostic accordingly. Failing that, option 1 (matching conditional compilation) is already a best practice for clarity, even if it adds some verbosity.

What do others think? Is platform-specific access level common enough that we need special handling, or is explicit conditional compilation acceptable here?

1 Like

The ability to declare member visibility in excess of the containing type’s visibility was a conscious design decision.

  • The compiler should not warn when a broader level of access control is used within a type with more restrictive access, such as internal within a private type. This allows the designer of the type to select the access they would use were they to make the type more widely accessible. (The members still cannot be accessed outside the enclosing lexical scope because the type itself is still restricted, i.e. outside code will never encounter a value of that type.)
5 Likes

Furthermore, even if this change happened, there would have to be an explicit carve-out for nested private types, or else there would be no way to provide access to the members of such a type:

struct Outer {
  // This is not accessible outside of `Outer`, but other members of
  // `Outer` should be able to access its members.
  private struct Inner {
    /* what goes here */ var innerMember = 5
  }

  func outerMember() {
    print(Inner().innerMember)
  }
}

There's currently no way to spell the exact (i.e., effective) visibility of innerMember:

  • It's not private, else it wouldn't be accessible from outerMember.
  • It's not fileprivate, because it's not accessible to declarations outside of Outer but in the same file.
  • It's not internal or public for similar reasons as above.
4 Likes

Hm, perhaps I'm missing something, but why isn't the answer to "what goes here?" simply "nothing"? I don't really have strong feelings about the pitch otherwise, just don't totally understand this objection.

My reasoning would be that "nothing" is equivalent to a declared visibility of internal (which then, of course, can be reduced based on the outer scope). So, if the original pitch is arguing "it's confusing when the declared visibility differs from the effective visibility", then it still needs to admit an exception for the "nothing" case to work as it does today.

5 Likes

Ah, gotcha—my conception has always been closer to "as visible as the containing scope*" though admittedly this understanding requires a couple of footnotes:

* except that public must always be explicit**
** and public on an extension counts as 'explicit' for all contained members

:sweat_smile:

1 Like

For users who do want to opt in to keeping explicit member declarations at or below the containing access levels, this could be a nice contribution to swift-lint [1], particularly if the rule supported automatic fixes. I didn’t see a rule on point.

[1] swift-lint: https://github.com/realm/SwiftLint

Given that revisiting this decision inevitably leads to rehashing the entire access modifier debate, and since folks above have given very practical examples of where the current behavior is in active use, I think it would be wise to leave things be and, for those who need would like to enforce a certain practice, explore linter settings that they can opt into.

[Edit: exactly as @wes1 says]

5 Likes

SwiftLint has a rule for that already. It's called lower_acl_than_parent and even supports auto-corrections.

3 Likes

Such warnings are easily handled by linters and fixed by formatters. Changing current access level rules will explode existing codebases.

What you propose seems to be suitable for your project, but not for general usage. Such changes require introducing new keyword(s) for nested types and implementation in compiler. This way it is not a pitch about purely additive diagnostic improvement.

Thank you all for the thoughtful feedback and for taking the time to explain the design rationale behind the current behavior. I really appreciate the references to the original SE-0025 proposal and the practical examples you've shared.

@ksluder - Thank you for linking to the specific design decision in the evolution proposal. I wasn't aware that this was an intentional choice to allow developers to declare the access they would use if they were to make the type more widely accessible. That's a really valuable insight.

@allevato - Your example with nested private types is particularly illuminating. I hadn't considered that edge case, and you're right that there's no way to spell the exact effective visibility in that scenario.

@SimplyDanny - Thanks for confirming that SwiftLint's lower_acl_than_parent rule already handles this with auto-corrections!

I completely understand now that this shouldn't be a compiler-level change, especially given it would conflict with a conscious design decision and break existing codebases. The linter approach makes much more sense for teams that want to enforce this style.

That said, I'm curious about something: given that there was enough demand for this pattern that SwiftLint implemented lower_acl_than_parent as a rule, why wasn't this offered as the Swift compiler feature from the beginning?

I'm asking purely out of curiosity about the language design process - not suggesting we revisit the decision! Just trying to understand the philosophy better.

Thanks again for the education! :folded_hands:

I don't mean to speak for the compiler team here, but… if I recall correctly, the general thinking is that stylistic choices like this one (where the effects are fully supported/intentional) are best left to a linting tool, not to the compiler.

1 Like

This does not seem correct. If a listing tool-developing team is not at the very least acquired, after 11 years, I consider the official word to be clearly advertised that nothing is best left to a linting tool, as linting tools are not useful.

(I think it would be better to have a clearly advertised official stance that is not that one! But given how linters are looked down upon by certain groups, the lack of official support appears to send a related message.)

That is not, to my knowledge, the official position of the Swift project nor Apple (though I am happy to be corrected by somebody more knowledgeable.)

1 Like

Strong -1: Even when a type as a whole has low visibility, it still can have parts of its interface designed with various viability in mind.

I strongly prefer to keep visibility modifiers in a way so you can publish a type by changing a single keyword on a main declaration, without revisiting its entire interface. For that, I set visibility of each type member to its “at most“, disregarding its containing entity.

1 Like