Skip to content

Commit 69098a8

Browse files
committed
Make optional var properties default to nil for non-public inits
Fixes #2.
1 parent 105ba43 commit 69098a8

File tree

4 files changed

+186
-37
lines changed

4 files changed

+186
-37
lines changed

README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A Swift Macro for enhanced automatic memberwise initializers, greatly reducing m
1313

1414
Informed by explicit developer cues, MemberwiseInit can more often automatically provide your intended memberwise `init`, while following the same safe-by-default semantics underlying [Swift’s memberwise initializers][swifts-memberwise-init].
1515

16-
> :warning: **Important**<br>
16+
> [!IMPORTANT]
1717
> `@MemberwiseInit` is a Swift Macro requiring **swift-tools-version: 5.9** or later (**Xcode 15** onwards).
1818
1919
* [Quick start](#quick-start)
@@ -114,7 +114,7 @@ Attach to struct, actor *(experimental)*, or class *(experimental)*.
114114
<br> Drop underscore prefix from generated `init` parameter names, unless doing so would result in a naming conflict. (Ignored properties won’t contribute to conflicts.)
115115

116116
* `@MemberwiseInit(_optionalsDefaultNil: true)` *(experimental)*
117-
<br> Give all optional fields a default `init` parameter value of `nil`.
117+
<br> When set to `true`, give all optional properties a default `init` parameter value of `nil`. For non-public initializers, optional `var` properties default to `nil` unless this parameter is explicitly set to `false`.
118118

119119
### `@Init`
120120

@@ -489,14 +489,25 @@ public init(
489489

490490
### Experimental: Defaulting optionals to nil
491491

492-
`@MemberwiseInit(_optionalsDefaultNil: true)` automatically defaults all optional fields to `nil` in its initializer, trading off compile-time guidance.
492+
Use `@MemberwiseInit(_optionalsDefaultNil: Bool)` to explicitly control whether optional properties are defaulted to `nil` in the provided initializer:
493+
494+
* Set `_optionalsDefaultNil: true` to default all optional properties to `nil`, trading off compile-time guidance.
495+
* Set `_optionalsDefaultNil: false` to ensure that MemberwiseInit never defaults optional properties to `nil`.
496+
497+
The default behavior of MemberwiseInit regarding optional properties aligns with Swift’s memberwise initializer:
498+
499+
* For non-public initializers, `var` optional properties automatically default to `nil`.
500+
* For public initializers, MemberwiseInit follows Swift’s cautious approach to public APIs by requiring all parameters explicitly, including optionals, unless `_optionalsDefaultNil` is set to `true`.
501+
* `let` optional properties are never automatically defaulted to `nil`. Setting `_optionalsDefaultNil` to `true` is the only way to cause them to default to `nil`.
493502

494503
> **Note**
495-
> `@Init(default:)` is a planned future enhancement to generally specify default values, and is expected to supersede `_optionalsDefaultNil` due to its improved safety.
504+
> `@Init(default:)` is a planned future enhancement to generally specify default values, and will be a safer, more explicit alternative to `_optionalsDefaultNil`.
496505
497506
#### Explanation
498507

499-
This feature eases instantiation for types with numerous optional fields, like `Codable` structs that mirror loosely structured HTTP APIs. However, it has a drawback: when properties change, the compiler won’t flag outdated instantiations, risking unintended `nil` assignments and potential runtime errors.
508+
With `_optionalsDefaultNil`, you gain control over a default behavior of Swift’s memberwise init. And, it allows you to explicitly opt-in to your public initializer defaulting optional properties to `nil`.
509+
510+
Easing instantiation is the primary purpose of `_optionalsDefaultNil`, and is especially useful when your types mirror a loosely structured external dependency, e.g. `Codable` structs that mirror HTTP APIs. However, `_optionalsDefaultNil` has a drawback: when properties change, the compiler won’t flag outdated instantiations, risking unintended `nil` assignments and potential runtime errors.
500511

501512
In Swift:
502513

Sources/MemberwiseInit/MemberwiseInit.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public enum AccessLevelConfig {
1212
public macro MemberwiseInit(
1313
_ accessLevel: AccessLevelConfig = .internal,
1414
_deunderscoreParameters: Bool = false,
15-
_optionalsDefaultNil: Bool = false
15+
_optionalsDefaultNil: Bool? = nil
1616
) =
1717
#externalMacro(
1818
module: "MemberwiseInitMacros",

Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ public struct MemberwiseInitMacro: MemberMacro {
2525
throw MemberwiseInitMacroDiagnostic.invalidDeclarationKind(decl)
2626
}
2727
let configuredAccessLevel: AccessLevelModifier? = extractConfiguredAccessLevel(from: node)
28-
let optionalsDefaultNil: Bool =
29-
extractLabeledBoolArgument("_optionalsDefaultNil", from: node) ?? false
28+
let optionalsDefaultNil: Bool? =
29+
extractLabeledBoolArgument("_optionalsDefaultNil", from: node)
3030
let deunderscoreParameters: Bool =
3131
extractLabeledBoolArgument("_deunderscoreParameters", from: node) ?? false
3232

@@ -51,6 +51,10 @@ public struct MemberwiseInitMacro: MemberMacro {
5151
considering: properties,
5252
deunderscoreParameters: deunderscoreParameters,
5353
optionalsDefaultNil: optionalsDefaultNil
54+
?? defaultOptionalsDefaultNil(
55+
for: property.keywordToken,
56+
initAccessLevel: accessLevel
57+
)
5458
)
5559
}
5660
.joined(separator: ",\n")
@@ -290,6 +294,19 @@ public struct MemberwiseInitMacro: MemberMacro {
290294
)
291295
}
292296

297+
private static func defaultOptionalsDefaultNil(
298+
for bindingKeyword: TokenKind,
299+
initAccessLevel: AccessLevelModifier
300+
) -> Bool {
301+
guard bindingKeyword == .keyword(.var) else { return false }
302+
return switch initAccessLevel {
303+
case .private, .fileprivate, .internal:
304+
true
305+
case .public, .open:
306+
false
307+
}
308+
}
309+
293310
private static func formatParameter(
294311
for property: MemberProperty,
295312
considering allProperties: [MemberProperty],

Tests/MemberwiseInitTests/MemberwiseInitTests.swift

Lines changed: 150 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -84,29 +84,6 @@ final class MemberwiseInitTests: XCTestCase {
8484
}
8585
}
8686

87-
func testOptionlProperty() {
88-
assertMacro {
89-
"""
90-
@MemberwiseInit
91-
struct Person {
92-
let nickname: String?
93-
}
94-
"""
95-
} expansion: {
96-
"""
97-
struct Person {
98-
let nickname: String?
99-
100-
internal init(
101-
nickname: String?
102-
) {
103-
self.nickname = nickname
104-
}
105-
}
106-
"""
107-
}
108-
}
109-
11087
// MARK: - Test assignment variations
11188

11289
func testVarProperty() {
@@ -1825,7 +1802,128 @@ final class MemberwiseInitTests: XCTestCase {
18251802

18261803
// MARK: - Test _optionalsDefaultNil (experimental)
18271804

1828-
func testOptionalsDefaultNilFalse_AssignsNoDefaultToOptional() {
1805+
func testOptionalLetProperty_InternalInitNoDefault() {
1806+
assertMacro {
1807+
"""
1808+
@MemberwiseInit(.public)
1809+
public struct Person {
1810+
let nickname: String?
1811+
}
1812+
"""
1813+
} expansion: {
1814+
"""
1815+
public struct Person {
1816+
let nickname: String?
1817+
1818+
internal init(
1819+
nickname: String?
1820+
) {
1821+
self.nickname = nickname
1822+
}
1823+
}
1824+
"""
1825+
}
1826+
}
1827+
1828+
func testOptionalLetProperty_PublicInitNoDefault() {
1829+
assertMacro {
1830+
"""
1831+
@MemberwiseInit(.public)
1832+
public struct Person {
1833+
public let nickname: String?
1834+
}
1835+
"""
1836+
} expansion: {
1837+
"""
1838+
public struct Person {
1839+
public let nickname: String?
1840+
1841+
public init(
1842+
nickname: String?
1843+
) {
1844+
self.nickname = nickname
1845+
}
1846+
}
1847+
"""
1848+
}
1849+
}
1850+
1851+
// NB: Swift's memberwise init defaults optional vars to nil, which seems reasonable considering
1852+
// it only provides non-public initializers. Swift will never default lets to nil, however.
1853+
// I assume that automatically assigning lets precludes uncommon init flows where you'd want to
1854+
// assign the constant some other way.
1855+
func testOptionalVarProperty_InternalInitWithDefault() {
1856+
assertMacro {
1857+
"""
1858+
@MemberwiseInit(.public)
1859+
public struct Person {
1860+
var nickname: String?
1861+
}
1862+
"""
1863+
} expansion: {
1864+
"""
1865+
public struct Person {
1866+
var nickname: String?
1867+
1868+
internal init(
1869+
nickname: String? = nil
1870+
) {
1871+
self.nickname = nickname
1872+
}
1873+
}
1874+
"""
1875+
}
1876+
}
1877+
1878+
func testOptionalVarProperty_PublicInitNoDefault() {
1879+
assertMacro {
1880+
"""
1881+
@MemberwiseInit(.public)
1882+
public struct Person {
1883+
public var nickname: String?
1884+
}
1885+
"""
1886+
} expansion: {
1887+
"""
1888+
public struct Person {
1889+
public var nickname: String?
1890+
1891+
public init(
1892+
nickname: String?
1893+
) {
1894+
self.nickname = nickname
1895+
}
1896+
}
1897+
"""
1898+
}
1899+
}
1900+
1901+
// NB: With MemberwiseInit, Swift's default behavior can be disabled.
1902+
func testOptionalVar_OptionalsDefaultNilFalse_InternalInitNoDefault() {
1903+
assertMacro {
1904+
"""
1905+
@MemberwiseInit(_optionalsDefaultNil: false)
1906+
struct Product {
1907+
var discountCode: String?
1908+
}
1909+
"""
1910+
} expansion: {
1911+
"""
1912+
struct Product {
1913+
var discountCode: String?
1914+
1915+
internal init(
1916+
discountCode: String?
1917+
) {
1918+
self.discountCode = discountCode
1919+
}
1920+
}
1921+
"""
1922+
}
1923+
}
1924+
1925+
// NB: Confirms that `_optionalsDefaultNil: false` for optional let has no effect.
1926+
func testOptionalLet_OptionalsDefaultNilFalse_InternalIntiNoDefault() {
18291927
assertMacro {
18301928
"""
18311929
@MemberwiseInit(_optionalsDefaultNil: false)
@@ -1848,7 +1946,7 @@ final class MemberwiseInitTests: XCTestCase {
18481946
}
18491947
}
18501948

1851-
func testOptionalsDefaultNilTrue_ParameterValueDefaultsNil() {
1949+
func testOptionalLet_OptionalsDefaultNilTrue_InternalInitWithDefault() {
18521950
assertMacro {
18531951
"""
18541952
@MemberwiseInit(_optionalsDefaultNil: true)
@@ -1871,6 +1969,29 @@ final class MemberwiseInitTests: XCTestCase {
18711969
}
18721970
}
18731971

1972+
func testOptionalVar_OptionalsDefaultNilTrue_PublicInitWithDefault() {
1973+
assertMacro {
1974+
"""
1975+
@MemberwiseInit(.public, _optionalsDefaultNil: true)
1976+
public struct Person {
1977+
public var nickname: String?
1978+
}
1979+
"""
1980+
} expansion: {
1981+
"""
1982+
public struct Person {
1983+
public var nickname: String?
1984+
1985+
public init(
1986+
nickname: String? = nil
1987+
) {
1988+
self.nickname = nickname
1989+
}
1990+
}
1991+
"""
1992+
}
1993+
}
1994+
18741995
// MARK: - Test complex usage
18751996

18761997
func testNestedStructs() {
@@ -1924,16 +2045,16 @@ final class MemberwiseInitTests: XCTestCase {
19242045
assertMacro {
19252046
"""
19262047
@MemberwiseInit(.public, _deunderscoreParameters: true)
1927-
@MemberwiseInit(.internal)
1928-
@MemberwiseInit(.private, _optionalsDefaultNil: true)
2048+
@MemberwiseInit(.internal, _optionalsDefaultNil: false)
2049+
@MemberwiseInit(.private)
19292050
public struct Person {
1930-
@Init(.public) let _name: String?
2051+
@Init(.public) var _name: String?
19312052
}
19322053
"""
19332054
} expansion: {
19342055
"""
19352056
public struct Person {
1936-
let _name: String?
2057+
var _name: String?
19372058
19382059
public init(
19392060
name: String?

0 commit comments

Comments
 (0)