Surfaces
- Proposal: SE-NNNN
- Author: Holly Schilling
- Review Manager: TBD
- Status: Draft
- Implementation: Pitch
- Companion work: PHP RFC: Surfaces (draft, same author)
Introduction
Swift's access levels describe where a declaration may be used β a file, a module, a package, everywhere. They say nothing about which role a piece of code is playing when it uses it. A complicated class or actor routinely serves several distinct audiences: a plugin host exposes some methods to plugins and others to its ordinary clients; a queue is produced into by one collaborator and consumed from by another. Today the only tools for keeping those audiences apart are protocol abstraction β which not every consumer can or will adopt β documentation, and naming conventions.
This proposal introduces surfaces: named, type-scoped roles that partition a type's API. A member assigned to a surface is removed from the default view at its access level and becomes reachable only from a scope that explicitly grants itself that surface. A surface may bind a protocol composition, letting a granted value be handed to protocol-typed code. Granting is always permitted wherever the surface is visible: a surface is a declaration of intended audience and a mechanism for making a consumer's intent legible and reviewable β not an access-control gate.
public class PluginHost {
public surface PluginAPI
// Ordinary public API for users of the host.
public func run() { ... }
// On the PluginAPI surface: visible publicly, but reachable only
// from a scope that declares it is acting in the plugin role.
surface[PluginAPI] func registerCallback(_ cb: @escaping () -> Void) { ... }
}
func install(into host: PluginHost) {
grant surface[PluginAPI] on PluginHost {
host.registerCallback { ... } // OK β role declared
}
host.registerCallback { ... } // error: 'registerCallback' is on
// surface 'PluginAPI'; no grant in scope
}
The grant is the point. A diff containing grant surface[PluginAPI] on PluginHost tells a reviewer, in one line, that this code has taken on the plugin-facing role of the host β a fact that today lives only in convention and in the author's head.
Motivation
Protocols already solve this β where protocols apply
When code holds some Collection or constrains T: MutableCollection, the consumer's role is fully legible: the constraint is the declaration of intent, chosen at the use site and checked by the compiler. Protocol-oriented generic code needs nothing from this proposal.
Surfaces target the code that protocols structurally cannot reach: code that must hold a concrete type because it is stateful, identity-bearing, an actor, or the hub of a plugin registry. There, every member at a given access level is equally reachable, and nothing records which of the type's several responsibilities a given use site relies on. Roles partition the concrete; protocols contract the abstract. The two mechanisms are complementary, and the asymmetry is deliberate: a surface may bind a protocol, but a protocol may not declare surfaces (see Future directions).
The lost category-header pattern
Objective-C frameworks partitioned exactly this way with category headers: UIGestureRecognizerSubclass.h exists solely to separate subclasser/framework-facing members from the user-facing API, and importing it was the (greppable, reviewable) declaration that a file plays the subclassing role. Swift has no analog; the pattern degraded to doc comments reading "do not call this method directly." Surfaces restore it as a checked language construct: the surface is the category header, the grant is the import.
Reviewability, including of generated code
A grant is a single greppable line stating that a scope depends on a named role. This signal is most valuable to the person who did not write the code: a reviewer scanning a large diff β increasingly, a diff produced by an AI coding agent β needs to answer "is this change reaching further than its task requires?" Surfaces make scope-of-use an explicit, diffable artifact. They do not constrain what code can be written β granting is always permitted where the surface is visible β but reaching for a role now announces itself.
Test seams
An internal surface[Testing] member is visible module-wide (and to @testable clients) but reachable only under an explicit grant surface[Testing] β so "this code exercises a test-only seam" is stated in the test file rather than inferred, and a seam accidentally used in production paths is one grep away from discovery.
Proposed solution
Three constructs:
-
A surface declaration inside a nominal type names a role, gives it an access level, and optionally binds a protocol composition:
internal surface Testing public surface Producer: ProducerProtocol public surface Transport: Codable & Sendable -
A surface modifier on a member assigns it to a surface, in place of an access modifier. All members of a surface share the surface's access level:
surface[Producer] func ingest(_ v: Element) { ... } public surface[Writer](set) var count: Int // get public, set on Writer -
A grant declares, for a lexical region, that the code is acting in a role:
grant surface[Producer] on Queue { // statement form: this block q.ingest(x) } grant surface[Producer] on Queue // file-scope form: this file
Enforcement is entirely compile-time and consists of two checks: direct access to a surface member requires a covering grant in scope, and implicit conversion of a value to a surface's bound protocol type requires a covering grant at the conversion. Everything else β including use of an already-converted protocol-typed value β is ungated.
Detailed design
Declaring surfaces
surface-declaration β access-level-modifier? 'surface' identifier (':' protocol-composition-type)?
A surface declaration may appear in the body of a class, struct, enum, or actor, or in a same-module extension of one (see Extensions). Surface names are identifiers scoped to the declaring type; they are not types and occupy no namespace beyond their type. Declaring two surfaces with the same name on one type (across its body and same-module extensions) is an error.
Access level. A surface's access level bounds who can see its members and who can name it in a grant. If omitted, it defaults to internal, or to the containing type's access level if that is lower β exactly the default for ordinary members, and for the same reason: nothing becomes public API without the word public. A surface may not be more accessible than its containing type. private is not a permitted surface access level: the declaring type already holds all of its own surfaces, so a private surface would be reachable by no one it isn't already redundant for. (fileprivate is permitted and meaningful: other types in the same file must grant.)
Bound protocol composition. The composition after : is the surface's single contract. Swift's P & Q composition is itself one protocol type, so binding a composition preserves the one-role-one-contract discipline while avoiding an artificial one-protocol limit. Conformance is checked as described under Bound protocols.
Interaction with access-level semantics. A surface member is visible per the surface's access level and additionally gated by the grant requirement. An internal surface[Testing] member is therefore no more reachable across the module boundary than a plain internal member β the access level does its ordinary job β but within the module its use additionally requires a stated role. The access level answers who can see it; the grant answers in what capacity they touch it. Because any scope that can see a surface may grant it, surfaces remain a signal, not an enforcement mechanism, throughout their visible scope.
Surface members
public surface[Writer](set) var count: Int = 0 // get: public; set: Writer
surface[Producer] private(set) var lastSize = 0 // get: Producer; set: private
surface[Producer, Consumer] var depth: Int // on either role
surface[Creation] init(name: String) // gated construction
surface[PluginAPI] static func shared() -> Host // static: metatype receiver
surface[...] occupies the access-modifier slot for the operation it governs and is mutually exclusive with an access modifier in that slot; the member takes the surface's access level. Naming several surfaces places the member on each (reachable under a grant of any one); all named surfaces must have the same access level, since the member can have only one.
Member kinds that may carry a surface in this proposal: methods, initializers, stored and computed properties, subscripts, and static/class members. For a static member the receiver is a metatype whose static type is always known, so the ordinary check applies. Not permitted in this proposal: enum cases (gating pattern matching is deferred), operators, nested type declarations, typealias, and protocol requirements (see Future directions).
A surface[...] modifier may name only a surface declared or inherited by the member's immediately containing nominal type. A nested type may not place members on its container's surfaces: a grant covers receivers whose static type is the granted type or a subtype, and a nested type is not a subtype of its container, so such a member's surface could never be satisfied by any well-formed grant. A nested type declares its own surfaces (roles spanning a type family are a Future direction).
Asymmetric accessors. The existing rule β the setter may not be more accessible than the getter β extends to the lattice: the set-side audience must be a subset of the get-side audience. public surface[Writer](set) is valid (Writer β everyone); surface[Producer] public(set) is not (widening on set); surface[Producer] private(set) is valid. Placing different surfaces on get and set is not permitted in this proposal: two named roles are incomparable, so the subset rule has no defined answer.
The bracket syntax parses unambiguously: member-modifier position admits no expressions, so surface[ there can never be a subscript on a variable named surface, and after the closing ] the (set) production is the existing one. In statement position (the grant), grant surface is two adjacent identifiers, which no expression can begin with, so grant remains a contextual keyword and existing code using either word as an identifier is unaffected.
Grants
grant-statement β 'grant' 'surface' '[' surface-name-list ']' 'on' type-identifier brace-block
grant-declaration β 'grant' 'surface' '[' surface-name-list ']' 'on' type-identifier // file scope only
A grant names one or more surfaces of one type. The named surfaces must exist on the named type (declared or inherited) and be visible at the grant site per their access levels; otherwise the grant is an error. The statement form covers its brace block; the declaration form, permitted only at file scope, covers the entire file. Grants for several types are written as separate (possibly nested) grants.
Coverage. A grant grant surface[S] on C covers any receiver (or, for construction, any statically named type) whose static type is C or a subtype of C. A receiver statically typed as a supertype of C is never covered. Consequences:
- Widening down: a grant on
Queuecovers aPriorityQueuereceiver. - Narrowing up: a grant on
PriorityQueuedoes not cover a plainQueuereceiver. Because a subtype cannot redeclare an inherited surface name, naming the subtype in a grant can only mean "this inherited surface, for receivers this narrow or narrower" β a receiver-type lower bound and a finer intent declaration, for free. - Generic parameters: a receiver of type
TwhereT: Queueis covered βT's static upper bound isQueue. - Existentials: a receiver of type
any Pis never covered and needs no rule: member lookup on an existential consults only the protocol's requirements, and (per the witness rule below) surface members never witness ordinary requirements, so no surface member is reachable through an existential at all.
Lexical propagation. A grant covers all code lexically inside its region: closures of every kind (escaping, @Sendable, autoclosures), nested functions, defer bodies, and property-initializer expressions. Unlike actor isolation β which stops at @Sendable boundaries because a closure may execute in a different concurrency domain, making inherited isolation a false runtime claim β a grant has no runtime existence; the check runs once, at compile time, on code that lexically sits under the grant, so there is nothing an escaping closure can carry anywhere. Propagation stops only at nested nominal type declarations: a type declaration is a new self-contained context, and its members' roles should be declared where the type is, not inherited invisibly from an enclosing function. The file-scope declaration form, accordingly, covers everything in the file except that a type declared in the file still does not inherit the grant into its member bodies β file-scope grants exist to serve free functions and top-level code.
A grant never changes the granted type or the object; it widens only the granting region's view.
Access model
Every member has an accessibility pair: an access level (visibility, as today) and an audience β the default audience (everyone who can see it), or a set of named surfaces. Access to expr.m is permitted iff the ordinary access-level check passes and either m has the default audience, or the enclosing region holds a covering grant for one of m's surfaces, or the access site is inside the declaring type (or a subtype β see Inheritance), which holds its own surfaces intrinsically.
Grants never participate in name lookup or overload resolution. Lookup finds surface members exactly as it finds inaccessible members today; candidates are ranked by the existing rules; and a resolved reference to a surface member without a covering grant is diagnosed after the fact ("'ingest' is on surface 'Producer' of 'Queue'; add 'grant surface[Producer] on Queue'"), just as "inaccessible due to 'private' protection level" is today. Adding or removing a grant therefore can never change which declaration a piece of code refers to β only whether it compiles.
Bound protocols and the witness rule
A surface bound to a composition makes the type genuinely conform to each protocol in the composition: the conformance is nominal, uses ordinary witness tables, satisfies is/as? at runtime, and interoperates with all existing protocol-typed code. Two rules govern it:
Conformance rule. Every requirement of the bound composition must be witnessed by a member of that surface or by a default-audience member of the type, with ordinary signature compatibility. A member of a different surface does not count. Multiple surfaces may bind the same protocol if each independently satisfies it; a protocol may not be satisfied only by spanning two surfaces.
Witness rule. A surface member may witness only requirements of its own surface's bound composition. It is not a candidate witness for any other conformance of the type β including conformances declared in extensions and retroactive conformances declared in other modules.
The witness rule is what makes the design sound, and it closes two tunnels at once. Without it, extension Queue: Ingesting {} (an ordinary protocol that happens to match a surface member's signature) would let let i: any Ingesting = queue reach the surface member with no grant anywhere in the program; and func makeOne<T: Makeable>(_ t: T.Type) -> T { t.init() } would construct through a surface-gated init that witnessed Makeable.init. Under the rule, neither conformance exists unless the type also offers default-audience members to witness it. Every path from a concrete type to a role-typed value passes through a grant.
The conversion gate
What the grant gates, for a bound surface, is the implicit conversion of a statically-typed value to the bound protocol type β the handoff:
func fill(_ q: Queue) {
grant surface[Producer] on Queue {
sink(q) // Queue β any ProducerProtocol: gated here
pump(q) // Queue β some ProducerProtocol (generic): also gated
}
sink(q) // error: conversion requires 'Producer' grant
}
func sink(_ p: any ProducerProtocol) { p.ingest("x") } // ungated: p is already role-typed
func pump<P: ProducerProtocol>(_ p: P) { p.ingest("y") } // ungated for the same reason
Both the existential upcast and generic instantiation against a bound protocol are conversions and are gated β otherwise generics would be a grant-free tunnel around the gate. Code that already holds the protocol type uses it freely: the conversion was the reviewable moment, and gating every downstream touch would only produce noise. Metatypes follow the same shape: a Queue.Type value converts to a bound protocol's metatype under a grant, and downstream protocol-metatype-typed construction is deliberately ungated. Runtime casts (queue as? ProducerProtocol, is) succeed regardless of grants, consistent with the conformance being real; surfaces are compile-time intent, not a runtime boundary.
Surface-gated construction
surface[Creation] init(...) removes the initializer from the default audience. A construction expression C(...), C.init(...), or t.init(...) where t is a statically-typed metatype is permitted only under a covering grant on the statically known type (or a supertype declaring the surface, per the coverage rule). This subsumes the private-initializer factory pattern without forcing the factory to live inside the type: any scope declaring the Creation role may construct, and direct construction elsewhere announces itself in the diff. Within the hierarchy, super.init, self.init, and a type's own factory methods need no grant β a type holds its own and its ancestors' surfaces. Construction through a protocol init requirement is governed entirely by the witness rule above.
Inheritance and overrides
A surface is owned by the type that declares it; ownership fixes its member set, access level, and binding for the whole hierarchy. Subclasses inherit surfaces and hold them automatically β inherited surface members are reachable through self and other hierarchy instances without a grant, and conversion to an inherited bound protocol is ungated inside the hierarchy β but a subclass may not add members to an inherited surface. Were it able to, the role would denote different contracts for different subtypes and role-typed substitution would break. New surface members go on surfaces the subclass declares.
Overrides follow an enforced superset rule, with the override keyword supplying the natural check point: an override's audience must be a superset of the overridden member's. Widening to the default audience (surface[Writer] β public) is permitted; adding a surface the subclass itself declares is permitted; dropping or swapping a surface, or narrowing to private, is an error. An override of a witnessing member must also continue to satisfy the bound contract, which widening always does. final surface[Writer] func publish() pins a member to its surface for the hierarchy.
open composes as it does for ordinary members: a surface member of an open class is overridable across modules only if marked open, and the surface itself must then be public (a cross-module subclass must be able to see and restate it).
Extensions
A same-module extension may declare new surfaces on the extended type and may add members to the type's existing surfaces β consistent with Swift's general treatment of same-module extensions as part of the type, and deliberately so: it enables the Host+PluginAPI.swift file organization that is this feature's direct descendant of the Objective-C category-header pattern. A cross-module extension may do neither: it may not declare surfaces, and its members may not carry a surface modifier. Ownership, and therefore role meaning, never leaves the declaring module.
Synthesized members
- Memberwise initializer. Synthesized as today; its access level is capped by the lowest access level among stored properties, including surface access levels. It is not itself surface-gated β initialization is not role-play, and the synthesized init is a member of the type, which holds its own surfaces.
Codable,Equatable,Hashable. Synthesis occurs inside the type and includes surface stored properties normally (same-type access). A surface does not exclude a property fromCodable; useCodingKeysfor that, as today.
First-class references
Forming a first-class reference to a surface member β an unapplied method reference (queue.ingest), a key path (\Queue.count through a surface property or setter), or a #selector β requires the covering grant at the point of formation. The resulting value is an ordinary function or key path and is ungated thereafter, consistent with the conversion-gate principle: the reviewable moment is where the role-bearing reference is minted. @dynamicMemberLookup needs no special rule β its subscript takes a key path formed at the call site, where the formation gate applies.
Interoperability limits
@objcanddynamicmay not be combined with a surface modifier in this proposal: a selector is runtime-callable by any Objective-C code with no static type to key a check on.Mirrorand runtime reflection do not expose surface membership (there is no runtime metadata to expose; see ABI compatibility).
This list is deliberately short: every Swift receiver has a static type, so outside these two corners the check is sound rather than best-effort throughout the language Swift programmers actually write.
Source compatibility
surface and grant are contextual keywords; neither is reserved. surface[ appears only in member-modifier position, where no expression is valid, so it cannot collide with subscripting a variable named surface. A grant statement begins with two adjacent identifiers (grant surface), which no expression can, so a function or variable named grant β including one called with a trailing closure β parses exactly as before. The surface declaration (surface Name) likewise begins with two identifiers in declaration position. No existing code changes meaning or fails to compile.
Adopting a surface on a previously default-audience member is source-breaking for that member's clients (they must add grants) β an intentional property; see the migration note under ABI compatibility.
ABI compatibility
Surfaces are designed to have no ABI footprint:
- Symbols. Access levels do not appear in Swift's mangling grammar, and the file-discriminator applies only to
private/fileprivatedeclarations. Surface members are module-visible or wider and mangle identically to ordinary members. Moving an existing member onto (or off of) a surface changes no symbol: the change is source-breaking but binary-compatible, so already-shipped clients keep running and are confronted with the new role contract only when recompiled β the friendliest possible migration for a legibility feature. - Dispatch. Class members occupy vtable entries irrespective of audience; resilient dispatch thunks are keyed on symbols, which are unchanged.
- Conformances. A bound surface produces an ordinary conformance with an ordinary witness table, governed by the existing library-evolution rules wholesale β including the rule that a new conformance added to an existing resilient type must carry an availability annotation.
- Grants are pure use-site facts: they are never serialized, never appear in a
.swiftmoduleor.swiftinterface, and never cross a file boundary. - Runtime metadata. None. Surfaces are fully erased at runtime except for bound conformances. Runtime surface metadata could be added later as an ABI-additive change if ever wanted (Future directions).
Surface declarations (names, access levels, bindings, memberships) are serialized into the .swiftmodule and printed in .swiftinterface files per their access levels β an internal surface does not print, exactly like an internal member.
Implications on adoption
The feature back-deploys trivially: it imposes no runtime requirement, so libraries built with a supporting compiler run on any OS their code otherwise supports.
Staged implementation plan:
- SwiftSyntax prototype β a lint-level checker enforcing the grant and conversion rules over annotation comments or the proposed syntax via a syntax plugin, validating ergonomics on real plugin-architecture codebases before compiler work.
- Compiler implementation β surface declarations and serialization; the
surface[...]modifier;grantparsing (contextual, per the two-identifier rule); grant scopes as ASTScope nodes (BraceStmtScopegains a grant-bearing variant; the coverage query is a parent walk to the source file, following the same rails unqualified lookup already rides); the audience check at the existing access-control diagnostic point in Sema; the conversion gate at coercion and generic-instantiation checking; the witness rule in conformance checking.
@testable import deliberately does not auto-hold surfaces: even in tests, the grant is the signal.
Future directions
- Roles spanning a type family. A container and its nested types sharing one conceptual role (the
EmployeeManager/Employeecase) β surface groups or aliases declaring "these surfaces are one role," grantable in one statement. - Protocol requirement audiences. The standard library's underscored customization points (
_customIndexOfEquatableElement) are implementer-facing protocol requirements enforced today by naming shame β structurally a fixed-audience surface on a protocol. Deliberately excluded here: the audience there is language-defined (conformers), not author-named, so it is a narrower visibility feature, and admitting author-named surfaces on protocols would mandate role structure on every conformer, which ordinary protocol segregation already covers. - Per-binding grants β granting for a single value rather than every receiver of a type in the region.
- Different surfaces on get and set of one property, given a defined ordering story.
- Enum case surfaces and their interaction with pattern matching and exhaustiveness.
- Runtime surface metadata for reflective tooling (ABI-additive).
- Grant modifiers on declarations (
grant(...) func, on types and extensions) as sugar over the statement form.
Alternatives considered
Officializing @_spi. Swift already ships named API audiences: @_spi(PluginAPI) public func β¦ with @_spi(PluginAPI) import Host. SPI is the nearest existing relative and the obvious question. It differs in kind, not degree: SPI is access control (undecorated clients cannot see the member at all, and its symbols are withheld from the public interface), its granularity is the module boundary (within the declaring module SPI is invisible and records nothing), it carries no protocol binding, and the import-site declaration names a module relationship rather than a role at a use site. Surfaces are visibility-orthogonal, work within a module β where plugin hosts, test seams, and reviewable role declarations live β and gate the handoff to role-typed values. The two compose rather than compete; officializing SPI would be worthwhile and would still not provide any of this.
Protocols alone. Fully answered in Motivation: abstraction-as-role-declaration works only where consumers can hold the abstraction. The target of this proposal is code that must hold the concrete type.
Reviving protected. Swift rejected protected on the grounds that it protects nothing β it is a signal of intended audience, not an enforcement boundary, and its one hardcoded audience (subclasses) did not fit Swift's file-and-module model. This proposal accepts that critique completely and generalizes the part that was always valuable: if a modifier's real job is to declare an intended audience, the audience should be author-named and the declaration should appear on both sides β the member and the use site. Surfaces are what protected was trying to be, minus the pretense of protection.
C++-style friend declarations. Enumerate who may reach in, grant total access, and record nothing at the call site. Surfaces name roles rather than collaborators, slice rather than open the type, and put the declaration where the reviewer is looking.
Attribute-based grants (@grants(Queue.Producer) on declarations). Workable and considered at length; rejected as the primary form because the braced statement gives the grant a natural node in the scope tree (rather than a source range to track), scopes intent no wider than the use, and reads as control flow β which, for a reviewer, it is. The declaration-attached form remains available as future sugar.
Parentheses (surface(Producer)), matching private(set). Rejected: the asymmetric-accessor composition surface[Writer](set) needs the surface name and the operation selector in different bracket families to compose without ambiguity, and brackets are provably collision-free in every position the syntax occupies.
Per-member access levels within a surface. Rejected in favor of the transitive rule β the surface's access level applies to all its members β keeping one role, one contract, one audience. A role needing two visibilities is two roles.
Acknowledgments
This design was developed alongside the companion PHP Surfaces RFC, whose access model, hierarchy rules, and conversion gate it adapts to Swift's access-level, protocol, and library-evolution models. Thanks to the participants in Swift's early access-control discussions, whose critique of protected supplied the founding observation: the valuable half of that keyword was never protection, but the declaration of an intended audience.