Typed CodingKeys?

currently, i have a lot of database logic that’s built around String-backed CodingKey types. it is not a Codable-based system, but i understand Codable also has similar characteristics.

an example for the purposes of discussion:

struct Homeworld
{
    var id:Int 
    var climate:String 
}
extension Homeworld:BSONDecodable, BSONEncodable 
{
    enum CodingKey:String, Sendable 
    {
        case id = "i"
        case climate = "c"
    }
}
struct Species 
{
    var id:Int

    var portrait:Int
    var name:String 
    var homeworld:Homeworld 
    var isSapient:Bool
    var isEnslaved:Bool
}
extension Species:BSONDecodable, BSONEncodable 
{
    enum CodingKey:String, Sendable 
    {
        case id = "_id"
        case portrait = "p"
        case name = "n"
        case homeworld = "w"
        case isSapient = "s"
        case isEnslaved = "e"
    }
}

this works well enough, but we’ve often introduced bugs in query builder logic that could have been prevented by better-typing the coding keys.

for example, when referencing a field of a nested document in the (MongoDB) query language, one must write a keypath such as '$w.i', which is analogous to \.homeworld.id in swift.

because the coding keys are stringly-typed, it is not possible to meaningfully type the keypath expressions:

let planet:Mongo.KeyPath = Species.CodingKey.homeworld 
    + Homeworld.CodingKey.id

of course, it is easy to make a mistake without the guardrails of a strong type system. for example, you might forget to drill down to the nested id and return a keypath such as '$w', which has a pointee of type Homeworld and not Int.

it would be game-changing if we could specialize a keypath type to obtain something like:

let homeworld:Mongo.KeyPath<Species, Homeworld> = ...
let planet:Mongo.KeyPath<Homeworld, Int> = ...
return homeworld + planet // as Mongo.KeyPath<Species, Int>

but i have a hard time imagining how to set up something like this using features that currently exist in the language.

I feel like you could make a macro that takes a key path and breaks it apart to get the CodingKey cases out. Then you can wrap an Array<AnyCodingKey> in a struct that has generic parameters matching the KeyPath.

perhaps i’m misunderstanding, but i’m not seeing how this would add any type safety, since the expression macro would have no idea if the corresponding field type actually matches the type we are coercing it to.

given something like

let planet:Mongo.KeyPath<Species, Int> = #bson(\.homeworld.id)

how could the macro validate that while rejecting something like

let planet:Mongo.KeyPath<Species, Int> = #bson(\.homeworld)

?

You don't throw away the key path, you pass it to the initializer to guarantee that the types line up.

1 Like

break it apart in what sense? i've been frustrated lately with trying to break multipart keypaths into their components similarly to what @dynamicMemberLookup does. i'm just not finding the APIs i need but maybe theres something obvious i'm missing

i believe he is talking about analyzing the KeyPathComponentListSyntax within the macro. i’m not sure i understand how to associate those with the CodingKey enums though.

I’m entirely throwing out ideas without seeing if they’re actually viable (side effect being increased brusqueness, sorry about that in the last response), but I think you could expand it to something like this:

// #bsonish(\Base.foo.bar.baz)
let components = [
  Base.CodingKeys.foo,
  type(of: \Base.foo).Output.CodingKeys.bar,
  type(of: \Base.foo.bar).Output.CodingKeys.baz
]
return MongoKeyPath(components, derivedFrom: \Base.foo.bar.baz)

I say “something like” because I don’t remember if you can use type(of:) like that, but there are workarounds if you can’t.

i’m a little confused by your example. where did the Output member come from?

Some helper protocol, I guess. Swift Regret: Generic Parameters Aren't Members // -dealloc (I guess I should have called it Value but I was too lazy to look up what KeyPath actually used.)