there is my jss library, which is built atop JavaScriptKit, and provides a serialization-free system for converting structures to and from JavaScript objects.
i am not sure i follow. if anything, the tradeoff is reversed, JSON is the format that requires eager parsing of the entire AST, native JavaScript objects on the other hand give you the opportunity to extract data by key without needing to inspect all other fields of an object.
Oh I see. This is a scenario where we have already have an in-memory JavaScript object and we want to decode Swift objects from it. I was thinking along the lines of rewriting the parser to take an unparsed string, pass it to JSON.parse() and then decode from the resulting JavaScript objects. Unless there are JavaScript tricks that I'm not aware of (which could VERY easily be the case, considering my inexperience here), the latter has to fully parse AND allocate objects before the decode even starts.
Unlike present-day JSONEncoder/Decoder, this transformation is enacted only at compile time. It establishes a 1:1 bidirectional mapping between the compile-time variable name (imageURL) and the encoded name (image_url). That means no more round trip issues with global convertTo/FromSnakeCase strategies.
(If you aren't aware, JSONDecoder with convertFromSnakeCase reads image_url, converts it to camelCase as imageUrl, and attempts to match it against the Decodable type's available CodingKey values. However, when following normal Swift conventions, the struct property and its CodingKey will usually be named imageURL which does not match, yet still produces image_url via convertToSnakeCase.)
Like all macro attributes and other compile-time entities, these naming schemes are NOT recursively applied. Types embedded inside your struct are not affected.
How exactly do you achieve 1:1 bidirectional mapping?
A slight angle
Across my career I spent many extra man-hours thanks to these conversions... E.g. with log files or data json files containing one spelling and Swift sources containing a different spelling, and then I have to remember to manually unconvert them to match when searching... In personal projects I tend to stay away from those conversions altogether, whether that's snake_case fields in Swift - so be it, or be it camelCase fields in backend - equally, so be it.
Are the key transforms still available in the encoder / decoder? I have run into cases where different backends return the same model with different key encodings.
TBH, I kind of assumed the author of a particular struct wouldn't likely add both imageUrl and imageURL properties to their type. Of course that's not forbidden... the macro will probably need to be enhanced with diagnostics that detect collisions like these.
I'll admit I did face some interesting friction when adapting this behavior over from Rust serde. In Rust, the compiler semi-enforces snake case (struct fields) or PascalCase (enum variants), which makes this kind of confusion far less likely (unless you disable the built in linter). We don't have that at all in Swift, though the conventions strongly favor camelCase with all capital acronyms (except the first word). Thus, the "splitting" part of the algorithm is a little more extensive than Serde's.
i would lean towards having no case transformations in the new system, over time i have found this type of āfeatureā to be more of a nuisance than an assistance. i think many JSON APIs are adopting camelCase anyway, and i think it is not too much to ask those who are working with snake_case APIs to provide the manual mapping they want.
This currently isn't in the plans, but is not entirely out of the realm of possibility.
It feels like a straightforward ask because JSONEncoder/Decoder already do it. But the additional decoding performance cost of enabling such a feature in the new design is likely to be significantly higher, percentage-wise, compared to JSONDecoder's. The current design enables the decoder (in ideal circumstances) to avoid any allocations at all when processing keys by passing the JSONDecodable type a UTF8Span of the key and matching known keys against it. Enabling such a feature would require a separate allocation (maybe reusable) in addition to the dynamic string manipulation.
Even worse, it would conflict oddly with other features like @CodingKey and @DecodingAlias. Suppose I write a type like this:
@JSONCodable
struct Test {
@DecodingAlias("compat_name")
let userName: String
}
... and I tried to decode this under a global convertFromSnakeCase strategy. An input containing "user_name" would work just fine because that gets converted to "userName" and matches against the CodingFields generated for this type. But if the input contained "compat_name", then that would get blindly converted by the decoder into "compatName" and fails to match, since the type explicitly expects "compat_name".
To be clear, your preference is that types wanting to encode to snake_case should use @CodingKey() on every property in the struct? Like so?
@JSONCodable
struct Foo {
@CodingKey("prop_1")
let prop1: String
@CodingKey("prop_2")
let prop2: String
...
@CodingKey("prop_n")
let propN: String
}
It doesn't seem too problematic to me to just give them a convenience for that:
@JSONCodable(fieldNaming: .snake_case)
struct Foo {
let prop1: String
let prop2: String
...
let propN: String
}
Is the concern that the developer may often assume that the macro is performing a transformation in a particular way, but it actually produces something different in some unanticipated corner case?
yes, it just doesnāt feel worth it to have a magical .snake_case transform because then that pushes the burden of deducing how the data is āactuallyā formatted onto the person reading the code. it can be ambiguous, for example, does currencyUSD get mangled to currency_Usd, currency_usd, or currency_USD?
Idiomatic snake_case would be all lower case, so currency_usd. I see no reason not to provide a valid default implementation, especially if we can provide our own transforms alongside the provided ones, which was a big issue with the current decoders.
Understood. My hope was that clear documentation about the renaming algorithm would ameliorate this concern. But trusting every reader to read documentation? Maybe a bit of a stretch?
Rust's choice of snake_case identifiers gives them the upper hand here I suppose. It's a lot less likely that someone would misconstrue the transformation algorithms from all lowercase snake_case sources.
Definitely interested in +1s on either side of this discussion.
This would be really interesting to be able to do, but the transformations all happen within the macro expansion, which in Swift cannot execute any external code.
That seems pretty bad for scaling. How does the global .snake_case setting interact with locally defined mappings? As @taylorswift pointed out, there will be annoying keys that don't work with standard mappings. If all we have to do is override that single key, rather than switch to overriding all of them, that might be okay.
I still think not being able to set a single transform for an entire type is another major scaling issue. For large type hierarchy that's a lot of macro applications.
The case choice defined on a type's macro (I won't call it "global" because it's limited to that type's fieldsā"global" means an encoder/decoder-wide setting in my mind) is overridden by per-field @CodingKey annotations. So yes, you could just apply it to the exceptional cases. And even to the cases that you think could be ambiguous to uninformed readers, even if the type-level case produces the same value.
Awesome work!
I would love to have the snake_case field naming.
With a huge set of model entities already represented like that in a system, it would be quite a burden to provide keys for all properties.
That the keys round trip (in the sense that they do not with existing snakeCaseKeyEncoding/Decoding) is just awesome, and will eliminate one of my only issues with codability today.
Regarding the possibility for collisions, I think that is natural when mapping data, and a compiler error in the case of multiple properties mapping to the same key would be fine. I think that it would be a rare situation where you had both imageUrl and imageURL properties without some very specific reason to have them, that would also require specific handling in the serialized data.
Great work. Was not expecting the names to be autological.
My only thought incredibly minor thought was wondering if extensibility could be built in, so users could add their own custom key casing, e.g.:
static var snake_case: Self {
Self.init({ name in ā¦code in PR⦠})
}
static var alternatingCaps: Self {
Self.init({ name in ⦠}) // "aLtErNaTiNg CaPs"
}
@JSONCodable(fieldNaming: .alternatingCaps)
struct TestStruct {
However, that's incredibly minor. What's in the PR covers about 99.9% of cases