├── .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 | --------------------------------------------------------------------------------