Skip to content

Instantly share code, notes, and snippets.

@brennanMKE
Last active June 20, 2022 09:45
Show Gist options
  • Save brennanMKE/f0547fd06436b18c6615d24831312716 to your computer and use it in GitHub Desktop.
Save brennanMKE/f0547fd06436b18c6615d24831312716 to your computer and use it in GitHub Desktop.
Traits in Swift with Property Wrappers

Traits

Swift does not support attributes like C# which allow for annotating properties with arbitrary values which are then accessible through reflection. This is a powerful way to associate traits with a property. Instead of just having the type of String it can be marked up with traits for validation including some traits which add validation criteria which can be set by your policy.

In this code there are traits to set the min and max length for a string. For a password the policy could include a min/max length so that the additional traits do not have to be set. For some values which do not fit into such a category it can be helpful to mark it with explicit traits.

Logging and Sensitive Values

One example for the US is Social Security Number. It is not a password but it is sensitive and if values for properties are being logged it is best to not include that value in the logs.

Validation

When a user is entering values the associated property could use these traits for validation before allowing the instance to be created with the entered values.

Compromises

With property wrappers it is no longer possible to maintain the same structure with Codable or allow the struct to be immutable. That is unfortunate and so the choice must be made between adding traits to properties or preserving immutability and Codeable support.

Property Attributes

An alternative to Property Wrappers could be Property Attributes can be declared in a similar way without having to modify the stored property. It could remain immutable and compatible with Codable. The details of the attributues could be ready via Mirror.

import Foundation
enum Trait {
case search
case name
case number
case email
case phone
case password
case sensitive
case minLength(Int)
case maxLength(Int)
case range(ClosedRange<Int>)
case validated(Validator)
}
extension Trait: CustomStringConvertible {
var description: String {
let result: String
switch self {
case .search:
result = "search"
case .name:
result = "name"
case .number:
result = "number"
case .email:
result = "email"
case .phone:
result = "phone"
case .password:
result = "password"
case .sensitive:
result = "sensitive"
case .minLength(let length):
result = "Min Length: \(length)"
case .maxLength(let length):
result = "Max Length: \(length)"
case .range(let range):
result = "Range: \(range.lowerBound) to \(range.upperBound)"
case .validated(let validator):
result = "validated (\(validator.label))"
}
return result
}
}
struct ValidatorResult {
var isValid: Bool
var reason: String?
}
protocol Validator {
var label: String { get }
func validate(value: Any) -> ValidatorResult
}
struct EmailValidator: Validator {
var label: String { "email" }
func validate(value: Any) -> ValidatorResult {
guard let string = value as? String else {
return ValidatorResult(isValid: false, reason: "Invalid type for email")
}
let isValid = string.contains("@") && string.count >= 5
if !isValid {
return ValidatorResult(isValid: false, reason: "Invalid email format")
} else {
return ValidatorResult(isValid: true, reason: nil)
}
}
}
struct PasswordValidator: Validator {
var label: String { "password" }
func validate(value: Any) -> ValidatorResult {
guard let string = value as? String else {
return ValidatorResult(isValid: false, reason: "Invalid type for password")
}
let isValidLength = string.count >= 6 || string.count <= 20
if !isValidLength {
return ValidatorResult(isValid: false, reason: "Password must be between 6 and 20 characters")
} else {
return ValidatorResult(isValid: true, reason: nil)
}
}
}
protocol TraitPropertiesValidator {
func validate() -> [ValidatorResult]
}
extension TraitPropertiesValidator {
func validate() -> [ValidatorResult] {
var results: [ValidatorResult] = []
let mirror = Mirror(reflecting: self)
for child in mirror.children {
if let traitsProperty = child.value as? TraitsProperty {
results.append(contentsOf: traitsProperty.validate())
}
}
return results
}
}
extension Array where Element == ValidatorResult {
var isValid: Bool {
let firstInvalid = first(where: {
$0.isValid == false
})
return firstInvalid == nil
}
var invalidReasons: [String] {
filter { !$0.isValid }
.map {
"\($0.reason ?? "Unknown")"
}
}
func printInvalidResults() {
invalidReasons.forEach {
print($0)
}
}
}
@propertyWrapper
struct Traits<Value> {
let traits: [Trait]
var wrappedValue: Value
var projectedValue: Traits { self }
enum CodingKeys: String, CodingKey {
case wrappedValue
}
init(wrappedValue: Value, _ traits: [Trait]) {
self.wrappedValue = wrappedValue
self.traits = traits
}
var isValid: Bool {
true
}
}
protocol TraitsProperty {
var value: Any { get }
var traits: [Trait] { get }
func validate() -> [ValidatorResult]
}
extension Traits: TraitsProperty {
var value: Any {
wrappedValue
}
func validate() -> [ValidatorResult] {
var results: [ValidatorResult] = []
traits.forEach { trait in
switch trait {
case .validated(let validator):
results.append(validator.validate(value: value))
default:
break
}
}
return results
}
}
protocol MirrorPrintable {
func printMirror()
}
extension MirrorPrintable {
func printMirror() {
let mirror = Mirror(reflecting: self)
for child in mirror.children {
print("Property name:", child.label ?? "")
if let traitsProperty = child.value as? TraitsProperty {
print("Property Value: ", traitsProperty.value)
print("Type:", type(of: traitsProperty.value))
print("Traits: ", traitsProperty.traits)
} else {
print("Property Value:", child.value)
print("Type:", type(of: child.value))
}
print("")
}
}
}
public struct Person {
@Traits([.name])
var name: String = ""
@Traits([.email, .sensitive, .validated(EmailValidator())])
var email: String = ""
@Traits([.password, .sensitive, .validated(PasswordValidator())])
var password: String = ""
var color: String = ""
var count: Int = 0
}
extension Person: MirrorPrintable {}
extension Person: TraitPropertiesValidator {}
let person = Person(name: "Jeff", email: "", password: "", color: "Yellow", count: 7)
person.printMirror()
let results = person.validate()
if !results.isValid {
results.printInvalidResults()
}
// add projectedValue to use $ syntax
//print(person.$name.traits)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment