├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
└── Sources
└── Once
├── ClosureState.swift
└── Once.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Suyash Srijan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.2
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Once",
8 | products: [
9 | .library(
10 | name: "Once",
11 | targets: ["Once"]),
12 | ],
13 | dependencies: [],
14 | targets: [
15 | .target(
16 | name: "Once",
17 | dependencies: []),
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Once
2 |
3 | A property wrapper that allows you to enforce that a closure is called exactly once. This is especially useful after the introduction of [SE-0293](https://github.com/apple/swift-evolution/blob/main/proposals/0293-extend-property-wrappers-to-function-and-closure-parameters.md) which makes it legal to place property wrappers on function and closure parameters.
4 |
5 | # Motivation
6 |
7 | It’s very common to write code where you have a function with a completion handler which must be called with a success or failure value based on the underlying result of the work that the function does. But if you’re not careful, it can be easy to make mistakes where you don’t call the completion handler at all or call it more than once, especially if there’s complicated business logic or error handling.
8 |
9 | ```swift
10 | func fetchSpecialUser(@Once completion: @escaping (Result) -> Void) {
11 | fetchUserImpl { user in
12 | guard let user = user else {
13 | // oops, forgot to call completion(...) here!
14 | return
15 | }
16 |
17 | guard user.isPendingEmailVerification == false else {
18 | completion(.failure(.userNotVerified))
19 | return
20 | }
21 |
22 | if user.hasSubscription {
23 | switch getSubscriptionType(user) {
24 | case .ultimate, .premium:
25 | completion(.success(user))
26 | case .standard:
27 | completion(.failure(.noPaidSubscription))
28 | }
29 | }
30 |
31 | // ... more business logic here
32 |
33 | // oops, forgot a 'return' in the if-statement above,
34 | // so execution continues and closure is called twice
35 | // (and with an invalid result!).
36 | completion(.failure(.generic))
37 | }
38 | ```
39 |
40 | ## Usage
41 |
42 | Simply annotate your function parameters with `@Once` and you will get a runtime error if the closure is not called at all or called more than once.
43 |
44 | ```swift
45 | func fetchSpecialUser(@Once completion: @escaping (Result) -> Void) {
46 | fetchUserImpl { user in
47 | guard let user = user else {
48 | // runtime error: expected closure to have already been executed once!
49 | return
50 | }
51 |
52 | guard user.isPendingEmailVerification == false else {
53 | completion(.failure(.userNotVerified))
54 | return
55 | }
56 |
57 | if user.hasSubscription {
58 | switch getSubscriptionType(user) {
59 | case .ultimate, .premium:
60 | completion(.success(user))
61 | case .standard:
62 | completion(.failure(.noPaidSubscription))
63 | }
64 | }
65 |
66 | // ... more business logic here
67 |
68 | // runtime error: closure has already been invoked!
69 | completion(.failure(.generic))
70 | }
71 | ```
72 |
73 | #### Note: There's also `@ThrowingOnce` which you can use if the closure throws.
74 |
75 | ## Requirements
76 |
77 | - Swift 5.2 or above
78 |
79 | ## Limitations
80 |
81 | - The property wrapper can only be used with escaping closures. If you want to use it on a non-escaping closure, you will need to annotate it with `@escaping`. This is not ideal, but because the closure is stored inside the property wrapper, there is no way to say to the compiler that the closure does not escape when you know for a fact it doesn't (perhaps because your code is synchronous).
82 |
83 | ## Installation
84 |
85 | Add the following to your project's `Package.swift` file:
86 |
87 | ```swift
88 | .package(url: "https://github.com/theblixguy/Once", from: "0.0.1")
89 | ```
90 |
91 | or add this package via the Xcode UI by going to File > Swift Packages > Add Package Dependency.
92 |
93 |
94 | ## License
95 |
96 | ```
97 | MIT License
98 |
99 | Copyright (c) 2021 Suyash Srijan
100 |
101 | Permission is hereby granted, free of charge, to any person obtaining a copy
102 | of this software and associated documentation files (the "Software"), to deal
103 | in the Software without restriction, including without limitation the rights
104 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
105 | copies of the Software, and to permit persons to whom the Software is
106 | furnished to do so, subject to the following conditions:
107 |
108 | The above copyright notice and this permission notice shall be included in all
109 | copies or substantial portions of the Software.
110 |
111 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
112 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
113 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
114 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
115 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
116 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
117 | SOFTWARE.
118 | ```
119 |
--------------------------------------------------------------------------------
/Sources/Once/ClosureState.swift:
--------------------------------------------------------------------------------
1 | enum ClosureState {
2 | enum Throwness {
3 | case `throws`((T) throws -> U)
4 | case nothrows((T) -> U)
5 | }
6 | case queued(Throwness)
7 | case executed
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Once/Once.swift:
--------------------------------------------------------------------------------
1 | /// A property wrapper that wraps a throwing closure which is supposed to be called at least and at most once.
2 | /// If the closure is called more than once or not at all, you will get a runtime error.
3 | @propertyWrapper
4 | public class ThrowingOnce {
5 | fileprivate var state: ClosureState
6 |
7 | public var wrappedValue: (T) throws -> U {
8 | switch state {
9 | case let .queued(.throws(closure)):
10 | state = .executed
11 | return closure
12 | case let .queued(.nothrows(closure)):
13 | state = .executed
14 | return closure
15 | case .executed:
16 | fatalError("Closure has already been invoked once!")
17 | }
18 | }
19 |
20 | public init(wrappedValue closure: @escaping (T) throws -> U) {
21 | self.state = .queued(.throws(closure))
22 | }
23 |
24 | fileprivate init(state closureState: ClosureState) {
25 | self.state = closureState
26 | }
27 |
28 | deinit {
29 | switch state {
30 | case .queued:
31 | fatalError("Expected closure to have already been executed once!")
32 | case .executed:
33 | break
34 | }
35 | }
36 | }
37 |
38 | /// A property wrapper that wraps a closure which is supposed to be called at least and at most once.
39 | /// If the closure is called more than once or not at all, you will get a runtime error.
40 | @propertyWrapper
41 | public final class Once: ThrowingOnce {
42 | public override var wrappedValue: (T) -> U {
43 | switch state {
44 | case let .queued(.nothrows(closure)):
45 | state = .executed
46 | return closure
47 | case .queued(.throws):
48 | fatalError("Unreachable - this property wrapper cannot be initialized with a throwing closure!")
49 | case .executed:
50 | fatalError("Closure has already been invoked once!")
51 | }
52 | }
53 |
54 | public init(wrappedValue closure: @escaping (T) -> U) {
55 | super.init(state: .queued(.nothrows(closure)))
56 | }
57 |
58 | @available(*, unavailable, message: "Use @ThrowingOnce for throwing closures")
59 | public override init(wrappedValue closure: @escaping (T) throws -> U) {
60 | fatalError("Unreachable - this function is unavailable!")
61 | }
62 | }
63 |
--------------------------------------------------------------------------------