I used to choose between Swift’s static and class keywords by habit. If a function belonged to a type instead of an instance, I usually wrote static and moved on. That worked until I built a notification layer where each delivery channel needed its own type-level behavior.
Email and SMS builders shared validation and construction logic, but they produced different payloads and advertised different channel names. That small design made the difference practical: a class method can be overridden by a subclass, while a static method cannot. The runnable project at the end recreates this scenario and checks the behavior from a command-line executable.
The Decision Is Really About Customization
Both keywords declare members that are called on a type. Neither requires an instance. The useful question is not which spelling looks better, but whether subclasses should be able to replace that behavior.
I use static when one implementation should apply to the declaring type and all of its users. I use class on a class when subclass-specific behavior is an intentional extension point. This keeps the default choice conservative: a method is overridable only when the design has a reason for it.
There is also a basic language boundary. Structures and enumerations can declare static members, but they cannot declare class members because they do not participate in class inheritance. On a class, both spellings are available, subject to the override rules discussed below.
A Notification Builder Made the Difference Visible
The sample starts with a base MessageBuilder. A caller gives it a recipient, subject, and body. The builder validates the input and returns an outgoing message containing a channel, normalized destination, and channel-specific payload.
class MessageBuilder {
class var channelName: String { "generic" }
class func makePayload(from request: DeliveryRequest) -> String {
"\(request.subject): \(request.body)"
}
static func normalizedRecipient(_ value: String) throws -> String {
let result = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !result.isEmpty else { throw MessageError.emptyRecipient }
return result
}
}
The channel name and payload format are customization points, so they use class. Recipient normalization is a shared rule that every channel should follow, so it uses static. This is easier to review than making every type member overridable just in case.
class Methods Participate in Override Dispatch
The email builder replaces the base channel name and payload format:
final class EmailMessageBuilder: MessageBuilder {
override class var channelName: String { "email" }
override class func makePayload(from request: DeliveryRequest) -> String {
"Subject: \(request.subject)\n\n\(request.body)"
}
}
The override keyword is required because these declarations replace inherited class members. When code calls them through EmailMessageBuilder.self, Swift selects the email implementations. An SMS subclass can supply a different name and truncate its payload without duplicating the common validation flow.
This is type-level dynamic dispatch. It is similar in purpose to overriding instance methods, except the receiver is a class type rather than an object. No builder instance needs to be created just to select the format.
static Closes the Override Door
If I tried to override normalizedRecipient in EmailMessageBuilder, the compiler would reject the declaration. A static member is not an override point. That is useful here because trimming whitespace, lowercasing the destination, and rejecting an empty value are shared invariants.
// This is intentionally invalid and is not in the runnable target.
class BrokenBuilder: MessageBuilder {
override static func normalizedRecipient(_ value: String) throws -> String {
value
}
}
The failed example is important because calling syntax alone does not reveal the distinction. Both MessageBuilder.normalizedRecipient(...) and MessageBuilder.makePayload(...) look like calls on a type. The declaration controls whether subclasses can customize the result.
I do not use static merely as a performance hint. The meaningful effect in this design is semantic: it states that subclasses do not get a separate implementation through overriding.
Metatypes Let the Caller Pick a Class at Runtime
The practical benefit appears when the application selects a builder from configuration. The project stores builder types rather than builder objects:
func builder(for channel: String) -> MessageBuilder.Type? {
let builders: [MessageBuilder.Type] = [
EmailMessageBuilder.self,
SMSMessageBuilder.self
]
return builders.first {
$0.channelName == channel.lowercased()
}
}
MessageBuilder.Type means a class object whose instances would be MessageBuilder or one of its subclasses. Reading $0.channelName and later calling selectedType.build(...) preserve override dispatch. The caller does not need a switch statement containing the formatting rules for every channel.
I find this pattern useful for small factories, importers, exporters, and feature handlers when the set of implementations is controlled by the application. If implementations need stored dependencies or per-request state, ordinary instances and dependency injection may be clearer.
Type Properties Follow the Same Rule
The distinction is not limited to functions. A computed class var can be overridden, which is why each builder can return a different channelName. A static property cannot be overridden.
Stored type properties are declared with static. When I need subclass-specific type data, I normally expose an overridable computed class property and return a literal or private value from each subclass. That makes the customization visible in the class interface.
I also avoid mutable global state hidden behind either keyword. A type property is convenient, but shared mutable data can make tests order-dependent and introduce synchronization problems. Configuration that changes during a process usually belongs in an injected object or another explicitly owned component.
Why the Email Builder Is final
The sample marks EmailMessageBuilder as final. It can override the base class methods, but another class cannot inherit from it. That matches the example’s intent: email is a finished channel implementation, not another framework layer.
A final class cannot provide further subclass override points, so choosing class versus static inside that final class rarely changes extensibility. I still use the spelling that communicates the member’s role, but I do not add inheritance machinery to a type that is deliberately closed.
Likewise, final class func can close one inherited type method while leaving the containing class open for other overrides. I use that selectively; too many individually closed methods can be a sign that the inheritance boundary needs a simpler design.
Structs and Protocols Need a Slightly Different Mental Model
The project’s PreviewFactory is a struct, so its type method is static:
struct PreviewFactory {
static func make(for message: OutgoingMessage) -> String {
"[\(message.channel.uppercased())] \(message.payload)"
}
}
There is no inheritance decision to make for this value type. The method simply groups a stateless operation with the type that names its purpose. An enum with no cases can also be used as a namespace, although I prefer a real value type when creating values remains plausible.
Protocols declare type-level requirements with static. Conforming types provide an implementation appropriate to the conformer. This is different from declaring a static class member and expecting a subclass to override it. Protocol dispatch and class inheritance solve related but distinct design problems, so I keep those two questions separate.
Mistakes I Now Check During Review
- Is the member truly associated with the type, or does it need instance dependencies?
- Is subclass customization intentional and covered by a test?
- Would
staticcommunicate a rule that must stay consistent? - Is an inheritance hierarchy being used where a protocol and separate values would be simpler?
- Does a mutable type property hide process-wide state?
- Will a metatype-based factory remain understandable as implementations grow?
- Should a finished subclass be marked
final? - Do tests call the member through the base metatype as well as concrete types?
My default is still static for a type-level helper. I move to class when I can name the subclass variation the base type is designed to support. In the notification example, payload formatting is that variation; recipient normalization is not.
Download and Run the Complete Example
The complete example is a Swift Package for macOS and requires Swift 5.9 or later. It has no third-party dependencies, network requests, or credentials.
git clone https://github.com/2252408699/swift-static-class-notification-factory.git
cd swift-static-class-notification-factory
swift run
A successful run prints eight PASS lines followed by All notification factory checks passed. The checks cover subclass selection, metatype dispatch, the shared static validation rule, email and SMS overrides, a struct factory, and invalid input.
Complete source code: swift-static-class-notification-factory on GitHub
