"genericizing" a keypath

@fswarbrick I'm in the midst of a project which involves encoding & decoding binary structs, and stumbled on this thread. It's enlightening to see how you've solved some of the challenges!

Maybe it's off-topic for this thread, but would you mind sharing your reasoning for implementing a new protocol rather than conforming to Codable? Thanks!

If you're just looking for coding of binary data you might take a look at this: mikeash.com: Friday Q&A 2017-07-28: A Binary Coder for Swift.

The problem I ran in to (as well as the fact that writing a Codable seems to require an immense amount of boilerplate) is that codables seems to depend on you being able to infer "field" lengths. This can be done with JSON, and even "pure binary" (where you can assume each field in the encoded data is the same length as the Swift property you are mapping it to). With the data I need to map you can't do that (for character strings, anyway). I need to be able to specify that "field 1 is 10 bytes, field 2 is 5 bytes", etc.

Possibly there might be a way to utilize my method in conjunction with Codable, but I'm far from that point at the moment.

I've made "significant" progress on my API, which I can post here after I clean it up a bit. Still learning a lot about Swift, so it will by no means be perfect.

Thanks for commenting!

1 Like

Latest version.

import struct Foundation.Data

public enum Endian {
    case big, little
}

public enum DataConversionOption {
    case encoding(String.Encoding)
    case endian(Endian)
}

extension DataConversionOption: Hashable {
    public var hashValue: Int { switch self { case .encoding: return 0; case .endian: return 1 } }

    public static func ==(lhs: DataConversionOption, rhs: DataConversionOption) -> Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

extension String.Encoding {
    public static let ebcdic = String.Encoding(rawValue: 0x8000_0C02)
#if os(zOS)
    public static var native = String.Encoding.ebcdic
#else
    public static var native = String.Encoding.ascii
#endif
    fileprivate init(conversionOptions: Set<DataConversionOption>) {
        for case .encoding(let newEncoding) in conversionOptions {
            self = newEncoding
            return
        }
        self = String.Encoding.native
    }
}

/// A protocol for types that can be converted to and from a Data "record buffer".
public protocol DataRecordConvertable {
    init?(buffer: Data, conversionOptions: Set<DataConversionOption>)
    func toDataRecord(buffer: inout Data, conversionOptions: Set<DataConversionOption>)
}

extension String: DataRecordConvertable {
    public init?(buffer: Data, conversionOptions options: Set<DataConversionOption>) {
        /* check for first "non-nul" (binary zero) byte; if none, buffer is all nuls, so set to nil */
        guard buffer.first(where: { $0 != 0 } ) != nil else { return nil }
        guard var decodedString = String(data: buffer, encoding: String.Encoding(conversionOptions: options))
        else { fatalError("Decoding error") }
        /* remove trailing spaces */
        while decodedString.hasSuffix(" ") {
            decodedString.removeLast()
        }
        self = decodedString
    }

    public func toDataRecord(buffer: inout Data, conversionOptions options: Set<DataConversionOption>) {
        /* make sure string is "same size" as buffer */
        func rightSize() -> String {
            var str = self
            let diff = buffer.count - count
            if diff > 0 {
                str.append(String(repeating: " ", count: diff))  // append trailing spaces
            }
            else {
                str.removeLast(0 - diff)  // truncate to proper length
            }
            return str
        }

        if let dataRecord = rightSize().data(using: String.Encoding(conversionOptions: options)) {
            buffer = dataRecord
        }
    }
}

extension Character: DataRecordConvertable {
    public init?(buffer: Data, conversionOptions options: Set<DataConversionOption>) {
        guard let asString = String(buffer: buffer, conversionOptions: options) else { return nil }
        self = Character(asString.isEmpty ? " " : asString)
    }

    public func toDataRecord(buffer: inout Data, conversionOptions options: Set<DataConversionOption>) {
        String(self).toDataRecord(buffer: &buffer, conversionOptions: options)
    }
}

extension Int: DataRecordConvertable {}
extension UInt: DataRecordConvertable {}
extension Int64: DataRecordConvertable {}
extension UInt64: DataRecordConvertable {}
extension Int32: DataRecordConvertable {}
extension UInt32: DataRecordConvertable {}
extension Int16: DataRecordConvertable {}
extension UInt16: DataRecordConvertable {}
extension Int8: DataRecordConvertable {}
extension UInt8: DataRecordConvertable {}

/* DataRecordConvertable implementation for all integer types */
extension FixedWidthInteger {
    public init(buffer: Data, conversionOptions options: Set<DataConversionOption>) {
        self = Self.endianConverted(buffer.withUnsafeBytes { $0.pointee }, conversionOptions: options)
    }

    public func toDataRecord(buffer: inout Data, conversionOptions options: Set<DataConversionOption>) {
        var myself = Self.endianConverted(self, conversionOptions: options)
        buffer = Data(bytes: &myself, count: MemoryLayout<Self>.size)
    }

    private static func endianConverted(_ number: Self, conversionOptions: Set<DataConversionOption>) -> Self {
        for case .endian(let endian) in conversionOptions {
            return (endian == .big) ? number.bigEndian : number.littleEndian
        }
        return number
    }
}

public struct RecordPropertyMap<Record: RecordPropertyMapable> {
    /* note, though the following are declared returning Int?, at this point we always return nil.
       The return Int? is for something that may or may not work out in the end... */
    fileprivate let fromData: (inout Record, Data, inout Int) -> Int?
    fileprivate let toData: (Record, inout Data, inout Int) -> Int?

    init(length len: Int = 1) {
        precondition(len > 0, "Length must be positive value")
        fromData = { $2 += len; return nil }
        toData = { $2 += len; return nil  }
    }

    init<Value: FixedWidthInteger>(as type: Value.Type) {
        let len = MemoryLayout<Value>.size
        fromData = { $2 += len; return nil  }
        toData = { $2 += len; return nil  }
    }

/*  I can't recall what this was supposed to be used for ...
    init<Value: FixedWidthInteger>(_ v: Value) {
        let len = MemoryLayout<Value>.size
        fromData = { $2 += len; return nil  }
        toData = { $2 += len; return nil  }
    }
*/

/*  not used at this point... */
    init<Value: FixedWidthInteger>(as: Value.Type, ref myself: inout RecordPropertyMap<Record>?,
                                       conversionOptions options: Set<DataConversionOption> = []) {
        let len = MemoryLayout<Value>.size
        fromData = { record, buffer, start in
            start = start + len
            return nil
        }
        toData = { record, buffer, start in
            start = start + len
            return nil
        }
        myself = self
    }

    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, Value>,
                                       length len: Int = 1,
                                       conversionOptions options: Set<DataConversionOption> = []) {
        precondition(len > 0, "Length must be positive value")
        var options = options
        options.insert(.endian(Record.binaryEndianess))
        options.insert(.encoding(Record.stringEncoding))
        fromData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                record[keyPath: property] = value
            }
            return nil
        }
        toData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            record[keyPath: property].toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            return nil
        }
    }

    /* inits for Arrays */
    init<Value: DataRecordConvertable> (_ property: WritableKeyPath<Record, [Value]>,
                                        length len: Int = 1,
                                        occurs elementCount: Int,
                                        conversionOptions options: Set<DataConversionOption> = []) {
        precondition(len > 0, "Length must be positive value")
        var options = options
        options.insert(.endian(Record.binaryEndianess))
        options.insert(.encoding(Record.stringEncoding))
        toData = { record, buffer, start in
            precondition(elementCount == record[keyPath: property].count, "Unexpected condition")
            record[keyPath: property].forEach {
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                $0.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            }
            return nil
        }
        fromData = { record, buffer, start in
            record[keyPath: property].reserveCapacity(elementCount)
            for _ in 0 ..< elementCount {
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                    record[keyPath: property].append(value)
                }
            }
            return nil
        }
    }

    init<Value: DataRecordConvertable, Prefix: FixedWidthInteger> (
                    _ property: WritableKeyPath<Record, [Value]>, length len: Int = 1,
                    occursPrefixed: Prefix.Type, conversionOptions options: Set<DataConversionOption> = []) {
        precondition(len > 0, "Length must be positive value")
        var options = options
        options.insert(.endian(Record.binaryEndianess))
        options.insert(.encoding(Record.stringEncoding))
        fromData = { record, buffer, start in
            let (first, last) = (start, start + MemoryLayout<Prefix>.size)
            start = last
            let elementCount = Int(Prefix(buffer: buffer[first ..< last], conversionOptions: options))
            record[keyPath: property].reserveCapacity(elementCount)
            for _ in 0 ..< elementCount {
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                    record[keyPath: property].append(value)
                }
            }
            return nil
        }
        toData = { record, buffer, start in
            let prefixLength = MemoryLayout<Prefix>.size
            let (first, last) = (start, start + prefixLength)
            start = last
            let elementCount = Prefix(record[keyPath: property].count)
            elementCount.toDataRecord(buffer: &buffer[first..<last], conversionOptions: options)
            record[keyPath: property].forEach {
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                $0.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            }
            return nil
       }
    }

    /* init for Optionals */
    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, Value?>,
                                       length len: Int = 1,
                                       conversionOptions options: Set<DataConversionOption> = []) {
        precondition(len > 0, "Length must be positive value")
        var options = options
        options.insert(.endian(Record.binaryEndianess))
        options.insert(.encoding(Record.stringEncoding))
        fromData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            record[keyPath: property] = Value(buffer: buffer[lower ..< upper], conversionOptions: options)
            return nil
        }
        toData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            record[keyPath: property]?.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            return nil
        }
    }

    /* for implicitly unwrapped optionals */
    /* does not work...
    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, Value!>,
                                       length len: Int = 1,
                                       conversionOptions options: Set<DataConversionOption> = []) {
        toData = { record, buffer, start in return nil }
        fromData = { record, buffer, start in return nil }
    }
    */

    /* specialized inits for FixedWidthIntegers; we can calculate the field length. */
    init<Value: FixedWidthInteger & DataRecordConvertable> (_ property: WritableKeyPath<Record, Value>,
                                                            conversionOptions options: Set<DataConversionOption> = []) {
        self.init(property, length: MemoryLayout<Value>.size, conversionOptions: options)
    }

    init<Value: FixedWidthInteger & DataRecordConvertable> (_ property: WritableKeyPath<Record, Value?>,
                                                            conversionOptions options: Set<DataConversionOption> = []) {
        self.init(property, length: MemoryLayout<Value>.size, conversionOptions: options)
    }
}

public protocol RecordPropertyMapable {
    static var recordPropertyMaps: [RecordPropertyMap<Self>] { get }
    static var stringEncoding: String.Encoding { get }
    static var binaryEndianess: Endian { get }
    init()
}

extension RecordPropertyMapable {
    public static var stringEncoding: String.Encoding { return .native }
#if os(zOS)  // z/OS is an EBCDIC big endian platform
    public static var binaryEndianess: Endian { return .big }
#else
    public static var binaryEndianess: Endian { return .little }
#endif

    public init(recordBuffer buffer: Data) {
        self.init()
        self.apply(recordBuffer: buffer)
    }

    public mutating func apply(recordBuffer buffer: Data) {
        var offset = 0
        Self.recordPropertyMaps.forEach {
            if let value = $0.fromData(&self, buffer, &offset) {
                print(value)
            }
        }
    }

    public func recordBuffer(basedOn buffer: Data) -> Data {
        var buffer = buffer
        var offset = 0
        Self.recordPropertyMaps.forEach {
            if let value = $0.toData(self, &buffer, &offset) {
                print(value)
            }
        }
        return buffer
    }

    public func recordBuffer(size: Int) -> Data {
        return recordBuffer(basedOn: Data(count: size))
    }
}

public struct Stuff {
    var name: String = "wxyz"
    var nbr: UInt32? = 0
    var char: Character = "?"
    var optStr: String? = "not empty"
    private var nbrsCount: UInt32 = 0
    var nbrs: [UInt32] = []
    var nbr1: UInt32 = 0

    public init() {}  // declare default initializer public
}

extension Stuff: RecordPropertyMapable {
    public static let binaryEndianess = Endian.little
    public static let stringEncoding = String.Encoding.ascii

    public static let recordPropertyMaps: [RecordPropertyMap<Stuff>] = [
        RecordPropertyMap(\.name, length: 5),
        RecordPropertyMap(\.nbr),
        RecordPropertyMap(\.char, conversionOptions: [.encoding(.ebcdic)]),

        /* the next 2 define the same area (comment out one or the other, depending on if you want to refer to it or not) */
//      RecordPropertyMap(\.optStr, length: 4),
        RecordPropertyMap(as: UInt32.self),

        RecordPropertyMap(\.nbrs, length: 4, occursPrefixed: UInt32.self),
    ]
}

let stuffBuffer = Data([
    0x39, 0x38, 0x37, 0x36, 0x20,   // ASCII "9876 "
    0xD2, 0x02, 0x96, 0x49,         // little-endian 1234567890 / 0x499602D2
    0x5A,                           // EBCDIC "!"
    0, 0, 0, 0,
    2, 0, 0, 0,
    99, 0, 0, 0,
    100, 0, 0, 0,
])

let sbArray = Array(stuffBuffer)
let sbSize = sbArray.count * MemoryLayout.size(ofValue: sbArray[0])
print(sbSize)
print(sbArray)

    /* two step initialization */
var stuff1 = Stuff()
stuff1.apply(recordBuffer: stuffBuffer)
print(stuff1)

    /* one step initialization */
let stuff2 = Stuff(recordBuffer: stuffBuffer)
print(stuff2)

    /* convert back and print */
let stuffBufferNew = stuff1.recordBuffer(size: sbSize)
print(Array(stuffBufferNew))
print("match: \(stuffBufferNew == stuffBuffer)")

stuff1.name = "abcdefg"  // will be truncated when converted
stuff1.optStr = "2112"
let stuffBuffer3 = stuff1.recordBuffer(size: sbSize)
print(Array(stuffBuffer3))

stuff1.name = "vwxyz"
stuff1.optStr = nil  // will not update record when converted
print(Array(stuff1.recordBuffer(basedOn: stuffBuffer3)))

New version. Quite happy with it. Comments/improvements welcome.

import Foundation

public enum Endian {
    case big, little
}

public enum DataConversionOption {
    case encoding(String.Encoding)
    case endian(Endian)
    case nbrFromString
}

extension DataConversionOption: Hashable {
    public var hashValue: Int {
        switch self {
        case .encoding: return 0
        case .endian: return 1
        case .nbrFromString: return 2
        }
    }

    public static func ==(lhs: DataConversionOption, rhs: DataConversionOption) -> Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

public typealias DataConversionOptions = Set<DataConversionOption>

public enum OccursDependingOn {
    case occursDepending
}

extension String {
    func leftPadding(toLength: Int, withPad: String = " ") -> String {
        guard toLength > count else { return self }
        let padding = String(repeating: withPad, count: toLength - count)
        return padding + self
    }
}

extension String.Encoding {
    public static let ebcdic = String.Encoding(rawValue: 0x8000_0C02)
    #if os(zOS)
    public static var native = String.Encoding.ebcdic037
    #else
    public static var native = String.Encoding.ascii
    #endif
    fileprivate init(conversionOptions: DataConversionOptions) {
        for case .encoding(let newEncoding) in conversionOptions {
            self = newEncoding
            return
        }
        self = String.Encoding.native
    }
}

extension MutableCollection {
    mutating func updateEach(update: (inout Element) -> ()) {
        for index in indices {
            update(&self[index])
        }
    }
}

/// A protocol for types that can be converted to and from a Data "record buffer".
public protocol DataRecordConvertable {
    static var defaultValue: Self { get }
    init?(buffer: Data, conversionOptions: DataConversionOptions)
    func toDataRecord(buffer: inout Data, conversionOptions: DataConversionOptions)
}

extension String: DataRecordConvertable {
    public static var defaultValue: String { return "" }

    public init?(buffer: Data, conversionOptions options: DataConversionOptions) {
        let firstNonNul = buffer.first { $0 != 0 }
        guard let _ = firstNonNul
        else { return nil }  // all bytes in buffer (slice) are nuls
        guard var decodedString = String(data: buffer, encoding: Encoding(conversionOptions: options))
        else { fatalError("Decoding error") }
        /* remove trailing spaces */
        while decodedString.hasSuffix(" ") {
            decodedString.removeLast()
        }
        self = decodedString
    }

    public func toDataRecord(buffer: inout Data, conversionOptions options: DataConversionOptions) {
        /* make sure string is "same size" as buffer */
        func rightSize() -> String {
            var str = self
            let diff = buffer.count - count
            if diff > 0 {
                str.append(String(repeating: " ", count: diff))  // append trailing spaces
            }
            else {
                str.removeLast(0 - diff)  // truncate to proper length
            }
            return str
        }

        if let dataRecord = rightSize().data(using: Encoding(conversionOptions: options)) {
            buffer = dataRecord
        }
    }
}

extension Character: DataRecordConvertable {
    public static var defaultValue: Character { return Character("\0") }

    public init?(buffer: Data, conversionOptions options: DataConversionOptions) {
        guard let asString = String(buffer: buffer, conversionOptions: options)
        else { return nil }
        self = Character(asString.isEmpty ? " " : asString)
    }

    public func toDataRecord(buffer: inout Data, conversionOptions options: DataConversionOptions) {
        String(self).toDataRecord(buffer: &buffer, conversionOptions: options)
    }
}

/* DataRecordConvertable implementation for all integer types */
extension FixedWidthInteger {
    public static var defaultValue: Self { return Self(0) }

    public init(buffer: Data, conversionOptions options: DataConversionOptions) {
        for case .nbrFromString in options {
            guard let str = String(buffer: buffer, conversionOptions: options)
            else { fatalError("data is nil...") }
            guard let myself = Self(str)
            else { fatalError("Data \(Array(buffer)) ('\(str)') failed converstion to numeric value") }
            self = myself
            return
        }
        self = Self.endianConverted(buffer.withUnsafeBytes { $0.pointee }, conversionOptions: options)
    }

    public func toDataRecord(buffer: inout Data, conversionOptions options: DataConversionOptions) {
        for case .nbrFromString in options {
            String(self).leftPadding(toLength: buffer.count, withPad: "0")
                .toDataRecord(buffer: &buffer, conversionOptions: options)
            return
        }
        var myself = Self.endianConverted(self, conversionOptions: options)
        buffer = Data(bytes: &myself, count: MemoryLayout<Self>.size)
    }

    private static func endianConverted(_ number: Self, conversionOptions: DataConversionOptions) -> Self {
        for case .endian(let endian) in conversionOptions {
            return (endian == .big) ? number.bigEndian : number.littleEndian
        }
        return number
    }
}

extension Int:    DataRecordConvertable {}
extension UInt:   DataRecordConvertable {}
extension Int64:  DataRecordConvertable {}
extension UInt64: DataRecordConvertable {}
extension Int32:  DataRecordConvertable {}
extension UInt32: DataRecordConvertable {}
extension Int16:  DataRecordConvertable {}
extension UInt16: DataRecordConvertable {}
extension Int8:   DataRecordConvertable {}
extension UInt8:  DataRecordConvertable {}

public struct RecordPropertyMap<Record: RecordPropertyMapable> {
    fileprivate let fromData:   (inout Record, Data, inout Int) -> Void
    fileprivate let toData:     (Record, inout Data, inout Int) -> Void

    init(padLength len: Int = 1) {
        precondition(len > 0, "Length must be positive value")
        fromData = { $2 += len }
        toData = { $2 += len }
    }

    init<Value: FixedWidthInteger>(padField _: Value.Type) {
        let len = MemoryLayout<Value>.size
        fromData = { $2 += len }
        toData = { $2 += len }
    }

    /* init for non-Optionals */
    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, Value>,
                                       length len: Int = 1,
                                       conversionOptions options: DataConversionOptions = []) {
        precondition(len > 0, "Length must be positive value")
        let options = RecordPropertyMap.insertDefaults(to: options)
        fromData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                record[keyPath: property] = value
            }
        }
        toData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            record[keyPath: property].toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
        }
    }

    /* init for Optionals */
    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, Value?>,
                                       length len: Int = 1,
                                       conversionOptions options: DataConversionOptions = []) {
        precondition(len > 0, "Length must be positive value")
        let options = RecordPropertyMap.insertDefaults(to: options)
        fromData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            record[keyPath: property] = Value(buffer: buffer[lower ..< upper], conversionOptions: options)
        }
        toData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            record[keyPath: property]?.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
        }
    }

    /* Fixed-count Array */
    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, [Value]>,
                                       length len: Int = 1,
                                       occurs elementCount: Int,
                                       conversionOptions options: DataConversionOptions = []) {
        precondition(len > 0, "Length must be positive value")
        let options = RecordPropertyMap.insertDefaults(to: options)
        fromData = { record, buffer, start in
            record[keyPath: property].reserveCapacity(elementCount)
            record[keyPath: property] = (0 ..< elementCount).flatMap { _ in
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                    return value
                }
                return nil
            }
        }
        toData = { record, buffer, start in
            precondition(elementCount == record[keyPath: property].count, "Unexpected condition")
            record[keyPath: property].forEach {
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                $0.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            }
        }
    }

    /* Prefixed Array */
    init<Value: DataRecordConvertable,
        Prefix: FixedWidthInteger>(_ property: WritableKeyPath<Record, [Value]>,
                                   length len: Int = 1,
                                   occursPrefixedBy _: Prefix.Type,
                                   conversionOptions options: DataConversionOptions = []) {
        precondition(len > 0, "Length must be positive value")
        let options = RecordPropertyMap.insertDefaults(to: options)
        fromData = { record, buffer, start in
            /* read prefix that contains the number of elements to follow */
            let (first, last) = (start, start + MemoryLayout<Prefix>.size)
            start = last
            let elementCount = Int(Prefix(buffer: buffer[first ..< last], conversionOptions: options))
            /* read the elements themselves */
            record[keyPath: property].reserveCapacity(elementCount)
            record[keyPath: property] = (0 ..< elementCount).flatMap { _ in
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                    return value
                }
                return nil
            }
        }
        toData = { record, buffer, start in
            /* write out the prefix */
            let prefixLength = MemoryLayout<Prefix>.size
            let (first, last) = (start, start + prefixLength)
            start = last
            let elementCount = Prefix(record[keyPath: property].count)
            elementCount.toDataRecord(buffer: &buffer[first ..< last], conversionOptions: options)
            /* write out the individual elements */
            record[keyPath: property].forEach {
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                $0.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            }
        }
    }

    /* this represents the "occurance" (count) value of the field referred to in the 'ref' parameter */
    init<Value: DataRecordConvertable,
        Count: FixedWidthInteger>(ref: WritableKeyPath<Record, [Value]>,
                                  is _: Count.Type,
                                  conversionOptions options: DataConversionOptions = []) {
        let options = RecordPropertyMap.insertDefaults(to: options)
        let len = MemoryLayout<Count>.size
        fromData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            /* */
            record[keyPath: ref] = [Value](repeating: .defaultValue,
                                           count: Int(Count(buffer: buffer[lower ..< upper],
                                                            conversionOptions: options)))
        }
        toData = { record, buffer, start in
            let (lower, upper) = (start, start + len)
            start = upper  // next start
            Count(record[keyPath: ref].count).toDataRecord(buffer: &buffer[lower ..< upper],
                                                           conversionOptions: options)
        }
    }

    /* This is the actual field, where the occurance count was already set via the 'ref' initializer */
    init<Value: DataRecordConvertable>(_ property: WritableKeyPath<Record, [Value]>,
                                       length len: Int = 1,
                                       _: OccursDependingOn = .occursDepending,
                                       conversionOptions options: DataConversionOptions = []) {
        precondition(len > 0, "Length must be positive value")
        let options = RecordPropertyMap.insertDefaults(to: options)
        fromData = { record, buffer, start in
            /* the number of elements has already been set with dummy (default) values,
             so here we simply replace them with the values from the buffer area */
            record[keyPath: property].updateEach { element in
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                if let value = Value(buffer: buffer[lower ..< upper], conversionOptions: options) {
                    element = value
                }
            }
        }
        toData = { record, buffer, start in
            record[keyPath: property].forEach { element in
                let (lower, upper) = (start, start + len)
                start = upper  // next start
                element.toDataRecord(buffer: &buffer[lower ..< upper], conversionOptions: options)
            }
        }
    }

    /* specialized inits for FixedWidthIntegers; we can calculate the field length. */

    init<Value: FixedWidthInteger & DataRecordConvertable>(_ property: WritableKeyPath<Record, Value>,
                                                           conversionOptions options: DataConversionOptions = []) {
        self.init(property, length: MemoryLayout<Value>.size, conversionOptions: options)
    }

    init<Value: FixedWidthInteger & DataRecordConvertable>(_ property: WritableKeyPath<Record, Value?>,
                                                           conversionOptions options: DataConversionOptions = []) {
        self.init(property, length: MemoryLayout<Value>.size, conversionOptions: options)
    }

    init<Value: FixedWidthInteger & DataRecordConvertable>(_ property: WritableKeyPath<Record, [Value]>,
                                                           occurs elementCount: Int,
                                                           conversionOptions options: DataConversionOptions = []) {
        self.init(property, length: MemoryLayout<Value>.size, occurs: elementCount, conversionOptions: options)
    }

    init<Value: FixedWidthInteger & DataRecordConvertable,
        Prefix: FixedWidthInteger>(_ property: WritableKeyPath<Record, [Value]>,
                                   occursPrefixedBy prefixType: Prefix.Type,
                                   conversionOptions options: DataConversionOptions = []) {
        self.init(property, length: MemoryLayout<Value>.size, occursPrefixedBy: prefixType, conversionOptions: options)
    }

    /* this inserts the default option (if there is one) if an option of that type has not already been set */
    private static func insertDefaults(to options: DataConversionOptions) -> DataConversionOptions {
        var options = options
        options.insert(.endian(Record.binaryEndianess))
        options.insert(.encoding(Record.stringEncoding))
        return options
    }
}

public protocol RecordPropertyMapable {
    static var stringEncoding: String.Encoding { get }
    static var binaryEndianess: Endian { get }
    static var recordPropertyMaps: [RecordPropertyMap<Self>] { get }
    init()
}

extension RecordPropertyMapable {
    public static var stringEncoding: String.Encoding { return .native }
    #if os(zOS)  // z/OS is an EBCDIC big endian platform
    public static var binaryEndianess: Endian { return .big }
    #else
    public static var binaryEndianess: Endian { return .little }
    #endif

    public init(recordBuffer buffer: Data) {
        self.init()
        self.apply(recordBuffer: buffer)
    }

    public mutating func apply(recordBuffer buffer: Data) {
        var offset = buffer.startIndex
        Self.recordPropertyMaps.forEach { $0.fromData(&self, buffer, &offset) }
    }

    public func recordBuffer(basedOn buffer: Data) -> Data {
        var buffer = buffer
        var offset = buffer.startIndex
        Self.recordPropertyMaps.forEach { $0.toData(self, &buffer, &offset) }
        return buffer
    }

    public func recordBuffer(size: Int) -> Data {
        return recordBuffer(basedOn: Data(count: size))
    }
}

Conforming structs:

public struct Stuff {
    var name:     String      = "wxyz"
    var nbr:      UInt32?     = 0
    var char:     Character   = "?"
    var shorts:   [Int16]     = []
    var optStr:   String?     = "not empty"
    var nbrs:     [UInt32]    = []
    var nbr1:     UInt32      = 0
    var bytes:    [UInt8]     = []
    var chars:    [Character] = []
    var strings1: [String]    = []
    var strings2: [String]    = []
    var strings3: [String]    = []
    var other: [OtherStuff]   = []

    public init() {}  // declare default initializer public
}

extension Stuff: RecordPropertyMapable {
    public static let binaryEndianess = Endian.little
    public static let stringEncoding = String.Encoding.ascii
    public static let recordPropertyMaps: [RecordPropertyMap<Stuff>] = [
        RecordPropertyMap(\.name, length: 5),
        RecordPropertyMap(\.nbr),
        RecordPropertyMap(\.char, conversionOptions: [.encoding(.ebcdic)]),
        RecordPropertyMap(ref: \.bytes, is: UInt32.self, conversionOptions: [.endian(.big)]),
        RecordPropertyMap(ref: \.chars, is: UInt32.self),
        RecordPropertyMap(\.shorts, occurs: 3),
        RecordPropertyMap(\.nbrs, occursPrefixedBy: UInt32.self),
        RecordPropertyMap(padField: UInt32.self),
        RecordPropertyMap(padLength: 5),
        RecordPropertyMap(\.nbr1, length: 8, conversionOptions: [.nbrFromString]),
        RecordPropertyMap(\.bytes, .occursDepending),
        RecordPropertyMap(\.chars, .occursDepending),
        RecordPropertyMap(ref: \.strings3, is: UInt32.self),
        RecordPropertyMap(\.strings1, length: 8, occurs: 2),
        RecordPropertyMap(\.strings2, length: 8, occursPrefixedBy: UInt32.self),
        RecordPropertyMap(\.strings3, length: 8, .occursDepending),
        RecordPropertyMap(\.other, length: 12, occurs: 3),
    ]
}

public struct OtherStuff {
    var i: Int32 = 0
    var s: String = ""

    public init() {}  // declare default initializer public
}

extension OtherStuff: RecordPropertyMapable {
    public static let binaryEndianess = Endian.little
    public static let stringEncoding = String.Encoding.ascii
    public static let recordPropertyMaps: [RecordPropertyMap<OtherStuff>] = [
        RecordPropertyMap(\.i),
        RecordPropertyMap(\.s, length: 8)
    ]
}

/* I hope to be able to make these three items a default implementation
   of the DataRecordConvertable protocol itself, but I've not gotten
   there quite yet... */
extension OtherStuff: DataRecordConvertable {
    public static var defaultValue: OtherStuff = OtherStuff()

    public init?(buffer: Data, conversionOptions options: DataConversionOptions) {
        self.init(recordBuffer: buffer)
    }

    public func toDataRecord(buffer: inout Data, conversionOptions: DataConversionOptions) {
        buffer = self.recordBuffer(basedOn: buffer)
    }
}

Tests:

let stuffBuffer = Data([
    0x39, 0x38, 0x37, 0x36, 0x20,   // ASCII "9876 "
    0xD2, 0x02, 0x96, 0x49,         // little-endian 1234567890 / 0x499602D2
    0x5A,                           // EBCDIC "!"
    0, 0, 0, 3,
    4, 0, 0, 0,
    1, 0,
    2, 0,
    3, 0,
    2, 0, 0, 0,
    99, 0, 0, 0,
    100, 0, 0, 0,
    0,0,0,0,0,0,0,0,0,  // "padding"; ignore
    0x30, 0x30, 0x30, 0x30, 0x39, 0x38, 0x37, 0x38,   // ASCII "00009876"
    0x31, 0x32, 0x33,
    0x39, 0x38, 0x37, 0x36,
    1, 0, 0, 0,
    0x41, 0x42, 0x43, 0x44, 0x20, 0x20, 0x20, 0x20,  // ASCII "ABCD    "
    0x61, 0x62, 0x63, 0x64, 0x20, 0x20, 0x20, 0x20,  // ASCII "abcd    "
    2, 0, 0, 0,
    0x61, 0x62, 0x63, 0x64, 0x20, 0x20, 0x20, 0x20,  // ASCII "abcd    "
    0x41, 0x42, 0x43, 0x44, 0x20, 0x20, 0x20, 0x20,  // ASCII "ABCD    "
    0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A,  // ASCII "STUVWXYZ"
    0xFF,0,0,0,
    0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x20,  // ASCII "XXXXXXX "
    0x0F,0,0,0,
    0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x20, 0x20,  // ASCII "YYYYYY  "
    0xF0,0,0,0,
    0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x20, 0x20, 0x20,  // ASCII "ZZZZZ   "
])

let sbSize = stuffBuffer.count * MemoryLayout.size(ofValue: stuffBuffer[0])
print(sbSize)
print(Array(stuffBuffer))
print(NSData(data: stuffBuffer))

/* two step initialization */
var stuff1 = Stuff()
stuff1.apply(recordBuffer: stuffBuffer)
print(stuff1)

/* one step initialization */
let stuff2 = Stuff(recordBuffer: stuffBuffer)
print(stuff2)

/* convert back and print */
let stuffBufferNew = stuff1.recordBuffer(size: sbSize)
print(Array(stuffBufferNew))
print(NSData(data: stuffBufferNew))
print("match: \(stuffBufferNew == stuffBuffer)")  // false if mapping has non-nul padding bytes

stuff1.name = "abcdefg"  // will be truncated when converted
stuff1.optStr = "2112"
let stuffBuffer3 = stuff1.recordBuffer(size: sbSize)
print(Array(stuffBuffer3))
print(NSData(data: stuffBuffer3))

stuff1.name = "vwxyz"
stuff1.optStr = nil  // will not update record when converted
stuff1.other[0].i = 98765
print(stuff1)
print(Array(stuff1.recordBuffer(basedOn: stuffBuffer3)))
print(NSData(data: stuff1.recordBuffer(basedOn: stuffBuffer3)))

This may not be totally relevant to the OP but I recently had a need to write values back to properties of an unknown type. Well, unknown unless you count a couple of protocols.

The requirements for the unknown type were :

public weak var subject: (AnyObject & KeyPathDeclaration)!

… where KeyPathDeclaration is :

public protocol KeyPathDeclaration
{
  static var keyPaths: [String : AnyKeyPath] { get }
}

extension KeyPathDeclaration
{
  public static func keyPathForProperty(name: String) -> AnyKeyPath?
  {
    return keyPaths[name]
  }
}

This is then implemented in a type like this :

struct Person
{
  var name: String
  
  var age: Int
}

extension Person : KeyPathDeclaration
{
  public static var keyPaths: [String : AnyKeyPath]
  {
    return ["name" : \Person.name, "age" : \Person.age]
  }
}

The problem arises when you need to use one of the key paths to set a value where the exact type of the target object is only known as :

  public weak var subject: (AnyObject & KeyPathDeclaration)!

… where trying to use subject[keyPath:_] to set a value provokes an error

"Cannot assign to immutable expression of type 'Any?'"

So, I played around for a bit and started thinking about some of the ideas in this thread. Here's what I came up with.

First, I create a new protocol :

public protocol KeyPathValueSetter
{
  func set<valueT>(_ value: valueT?, for keyPath: AnyKeyPath)
}

extension KeyPathValueSetter
{
  public func set<valueT>(_ value: valueT, for keyPath: AnyKeyPath)
  {
    if let keyPath = keyPath as? ReferenceWritableKeyPath<Self, valueT>
    {
      self[keyPath: keyPath] = value
    }
  }
}

Then I add in this protocol to the subject's requirements :

  public weak var subject: (AnyObject & KeyPathValueSetter & KeyPathDeclaration)!

Now I can set the value on the subject like this :

      let keyPath = type(of: subject).keyPathForProperty(name: propertyName)

      subject.set(value, for: keyPath)

Not quite a subscript but a lot more concise than some efforts I made to solve this :wink::sunglasses: