Search code examples
swiftgenericscodablephantom-types

Decoding generics with phantom types


I am trying to define a Currency type that would prevent numeric and alphabetic currency codes from getting mixed up:

public protocol ISO4217Type {}

public enum ISO4217Alpha: ISO4217Type {}
public enum ISO4217Num: ISO4217Type {}

public struct Currency<T: ISO4217Type> {

    public let value: String
}

extension Currency where T == ISO4217Alpha {

    public init?(value: String) {
        let isLetter = CharacterSet.letters.contains
        guard value.unicodeScalars.all(isLetter) else { return nil }
        self.value = value
    }
}

extension Currency where T == ISO4217Num {

    public init?(value: String) {
        let isDigit = CharacterSet.decimalDigits.contains
        guard value.unicodeScalars.all(isDigit) else { return nil }
        self.value = value
    }
}

This works great. Now, is it possible to add a Codable conformance that would throw a decoding error when trying to decode a currency code with the wrong payload? (For example, decoding USD as a numeric currency code.)


Solution

  • The key revelation was that it’s possible to customize the behaviour using static functions on the phantom type:

    public protocol ISO4217Type {
    
        static func isValidCode(_ code: String) -> Bool
    }
    
    public enum ISO4217Alpha: ISO4217Type {
    
        public static func isValidCode(_ code: String) -> Bool {
            let isLetter = CharacterSet.letters.contains
            return code.unicodeScalars.all(isLetter)
        }
    }
    
    public enum ISO4217Num: ISO4217Type {
    
        public static func isValidCode(_ code: String) -> Bool {
            let isDigit = CharacterSet.decimalDigits.contains
            return code.unicodeScalars.all(isDigit)
        }
    }
    
    public struct Currency<T: ISO4217Type> {
    
        public let value: String
    
        private init(uncheckedValue value: String) {
            self.value = value
        }
    
        public init?(value: String) {
            guard T.isValidCode(value) else { return nil }
            self.value = value
        }
    }
    
    extension Currency: Codable {
    
        public func encode(to encoder: Encoder) throws {
            var c = encoder.singleValueContainer()
            try c.encode(value)
        }
    
        public init(from decoder: Decoder) throws {
            let c = try decoder.singleValueContainer()
            let value = try c.decode(String.self)
            guard T.isValidCode(value) else {
                throw DecodingError.dataCorruptedError(in: c,
                    debugDescription: "Invalid \(type(of: T.self)) code")
            }
            self.init(uncheckedValue: value)
        }
    }