A library for decoding and encoding json, built on top of @pointfreeco's Parsing library.
- Introduction
- Quick start
- Motivation - why not use Codable?
- The
JSONValue
type - The JSON parsers
- Integration with Codable
- Benchmarks
- Installation
As mentioned above, this library is built using the Parsing library, which is a library that provides a consistent story for writing parsing code in Swift, that is, code that turns some unstructured data into more structured data. You do that by constructing parsers that are generic over both the (unstructured) input and the (structued) output. What's really great is the fact the these parsers can be made invertible (or bidirectional), meaning they can also turn structured data back into unstructured data, referred to as printing.
The JSONParsing library provides predefined parsers tuned specifically for when the input is json, giving you a convenient way of writing parsers capable of parsing (decoding) and printing (encoding) json. This style of dealing with json has a number of benefits compared to the Codable abstraction. More about that in the Motivation section.
Let's see what it looks like to decode and encode json data using this library. Imagine, for example, you have json describing a movie:
let json = """
{
"title": "Interstellar",
"release_year": 2014,
"director": "Christopher Nolan",
"stars": [
"Matthew McConaughey",
"Anne Hathaway",
"Jessica Chastain"
],
"poster_url": "https://www.themoviedb.org/t/p/w1280/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
"added_to_favorites": true
}
""".data(using: .utf8)!
First, we define a corresponding Movie
type:
struct Movie {
let title: String
let releaseYear: Int
let director: String
let stars: [String]
let posterUrl: URL?
let addedToFavorites: Bool
}
Then, we can create a JSON parser, to handle the decoding of the json into this new data type:
extension Movie {
static var jsonParser: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
Field("title") { String.jsonParser() }
Field("release_year") { Int.jsonParser() }
Field("director") { String.jsonParser() }
Field("stars") {
JSONArray { String.jsonParser() }
}
OptionalField("poster_url") { URL.jsonParser() }
Field("added_to_favorites") { Bool.jsonParser() }
}
}
}
Now, the Movie.jsonParser
can be used to decode json data into Movie
instances:
let decodedMovie = try Movie.jsonParser.decode(json)
print(decodedMovie)
// Movie(title: "Interstellar", releaseYear: 2014, director: "Christopher Nolan", stars: ["Matthew McConaughey", "Anne Hathaway", "Jessica Chastain"], posterUrl: Optional(https://www.themoviedb.org/t/p/w1280/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg), addedToFavorites: true)
But what's even cooler is that the very same parser, without any extra work, can also be used to encode movie values into json:
let jokerMovie = Movie(
title: "Joker",
releaseYear: 2019,
director: "Todd Phillips",
stars: ["Joaquin Phoenix", "Robert De Niro"],
posterUrl: URL(string: "https://www.themoviedb.org/t/p/w1280/udDclJoHjfjb8Ekgsd4FDteOkCU.jpg")!,
addedToFavorites: true
)
let jokerJson = try Movie.jsonParser.encode(jokerMovie)
print(String(data: jokerJson, encoding: .utf8)!)
// {"added_to_favorites":true,"director":"Todd Phillips","poster_url":"https://www.themoviedb.org/t/p/w1280/udDclJoHjfjb8Ekgsd4FDteOkCU.jpg","release_year":2019,"stars":["Joaquin Phoenix","Robert De Niro"],"title":"Joker"}
More information about the building blocks for constructing the JSON parsers can be found under the The JSON parsers section.
The default way to work with JSON in Swift is with Apple's own Codable framework. While it is a powerful abstraction, it does have some drawbacks and limitations. Let's explore some of them and see how the JSONParsing library addresses these issues.
One limitation of the Codable framework is that any given type can only have one way of being represented as JSON. To work around this limitation, one common approach is to introduce wrapper types that wrap a value of the result type and have a custom Decodable implementation. Then, when decoding the type, you first decode to the wrapper type and then extract the underlying value. While this approach works, it's cumbersome to introduce a new type solely for handling JSON decoding. Moreover, the wrapper type needs to be explicitly used whenever you want to decode to the underlying type with that specific decoding strategy.
As an example, let's consider the following type representing an RGB color:
struct RGBColor {
let red: Int
let green: Int
let blue: Int
}
What would be the corresponding json representation for this type? Would it be something like:
{
"red": 205,
"green": 99,
"blue": 138
}
Or perhaps:
"205,99,138"
The truth is, both representations are reasonable (as well as many other possibilities), and it's possible that you might have one API endpoint returning RGB colors in the first format, and another in the second format. But when using Codable, you would have to choose one of the formats to be the one used for the RGBColor
type. To handle both variants, you would have to define two separate types, something like RGBColorWithObjectRepresentation
and RGBColorWithStringRepresentation
, and conform both of them to Codable
, with the different decoding/encoding strategies.
Using the JSONParsing library, you can easily just create two separate parsers, one for each alternative:
extension RGBColor {
static var jsonParserForObjectRepresentation: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
Field("red") { Int.jsonParser() }
Field("green") { Int.jsonParser() }
Field("blue") { Int.jsonParser() }
}
}
static var jsonParserForStringRepresentation: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
JSONString {
Int.parser()
","
Int.parser()
","
Int.parser()
}
}
}
}
And now you can use whichever suits best in the given situation:
// in one place in the app
let colorJson1 = """
{
"red": 205,
"green": 99,
"blue": 138
}
""".data(using: .utf8)!
// decode
let color1 = try RGBColor.jsonParserForObjectRepresentation.decode(colorJson1)
print(color1)
// RGBColor(red: 205, green: 99, blue: 138)
// encode
let newColorJson1 = try RGBColor.jsonParserForObjectRepresentation.encode(color1)
print(String(data: newColorJson1, encoding: .utf8)!)
// {"blue":138,"green":99,"red":205}
// in another place in the app
let colorJson2 = """
"55,190,25"
""".data(using: .utf8)!
// decode
let color2 = try RGBColor.jsonParserForStringRepresentation.decode(colorJson2)
print(color2)
// RGBColor(red: 205, green: 99, blue: 138)
// encode
let newColorJson2 = try RGBColor.jsonParserForStringRepresentation.encode(color2)
print(String(data: newColorJson2, encoding: .utf8)!)
// "55,190,25"
If you want, you could even define a configurable function, dealing with both variants in the same place:
extension RGBColor {
static func jsonParser(useStringRepresentation: Bool = false) -> some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
if useStringRepresentation {
JSONString {
Int.parser()
","
Int.parser()
","
Int.parser()
}
} else {
Field("red") { Int.jsonParser() }
Field("green") { Int.jsonParser() }
Field("blue") { Int.jsonParser() }
}
}
}
}
try RGBColor.jsonParser(useStringRepresentation: false).decode(colorJson1)
// RGBColor(red: 205, green: 99, blue: 138)
try RGBColor.jsonParser(useStringRepresentation: true).decode(colorJson2)
// RGBColor(red: 205, green: 99, blue: 138)
Perhaps the most common way to run into the limitation of a type only being able to have one single Codable
conformance, is when dealing with the Date
type. In fact, it's so common, that the Codable framework even provides a special way of managing how Date
types are decoded/encoded, through the dateDecodingStrategy
/dateEncodingStrategy
properties available on JSONDecoder
and JSONEncoder
, respectively. While this does work, it's a little weird to have special handling for one specific type, that looks nothing like how you deal with all the other types. Also, having the configuration on the Encoder/Decoder types, means you can't have more than one date format in the same json object.
With JSONParsing on the other hand, the Date
type doesn't have to be handled as an exception. We saw above with the RGBColor
type, that we can just create a parser that matches the required representation that is used in the JSON API. The library also extends the Date
type with a static jsonParser(formatter:)
method, which allows constructing a json parser that decodes/encodes dates according to a given DateFormatter
:
let json = """
{
"date1": "1998-11-20",
"date2": "2021-06-01T13:09:09Z"
}
""".data(using: .utf8)!
struct MyType {
let date1: Date
let date2: Date
}
let basicFormatter = DateFormatter()
basicFormatter.dateFormat = "yyyy-MM-dd"
let isoFormatter = DateFormatter()
isoFormatter.dateFormat = "yyyy-MM-dd'T'HH':'mm':'ss'Z'"
extension MyType {
static var jsonParser: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
Field("date1") { Date.jsonParser(formatter: basicFormatter) }
Field("date2") { Date.jsonParser(formatter: isoFormatter) }
}
}
}
let parsedValue = try MyType.jsonParser.decode(json)
print(parsedValue)
// MyType(date1: 1998-11-20 00:00:00 +0000, date2: 2021-06-01 13:09:09 +0000)
let encodedJson = try MyType.jsonParser.encode(parsedValue)
print(String(data: encodedJson, encoding: .utf8)!)
// {"date1":"1998-11-20","date2":"2021-06-01T13:09:09Z"}
Codable has the really cool feature of being able to automatically synthesize the decoding and encoding implementations for Swift types, thanks to integration with the Swift compiler. Unfortunately, in practice, the automatically synthesized implementations will often not be correct for your use case, because it assumes that your json data and your Swift data types exactly match each other in structure. This will often not be the case, for various reasons. First, you might be dealing with JSON APIs that you don't own yourself and therefore might deliver data in a format that isn't ideal to your use case. But even if you do own the API code, it might be used by multiple platforms, which means you can't tailor it specifically to work perfectly with your Swift code. Also, Swift has some features, such as enums, that simply can't be expressed equivalently in json.
So in practice, when using Codable, you will often have to implement the decoding and encoding logic manually. And the problem in that situation, is that they have to be implemented separately. This means that, whenever the expected json format changes in any way, you have to remember to update both the init(from:)
(decoding) and the encode(to:)
(encoding) implementations accordingly.
With JSONParsing on the other hand, you can write a single json parser that can take care of both the decoding and the encoding (as was shown in the Quick start section). What this means is that you are guaranteed to always have the two transformations kept in sync as your json API evolves.
Recall how we previously defined a json parser for the RGBColor
type, where the json representation was a comma separated string. It looked like this:
extension RGBColor {
static var jsonParserForStringRepresentation: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
JSONString {
Int.parser()
","
Int.parser()
","
Int.parser()
}
}
}
}
let colorJson = """
"55,190,25"
""".data(using: .utf8)!
let color = try RGBColor.jsonParserForStringRepresentation.decode(colorJson)
print(color)
// RGBColor(red: 55, green: 190, blue: 25)
let newColorJson2 = try RGBColor.jsonParserForStringRepresentation.encode(color2)
print(String(data: newColorJson2, encoding: .utf8)!)
// "55,190,25"
In that example, it was used to highlight the fact that we can handle different json representations for the same type. However, it actually also shows off another great thing about the library, which is how its integration with the Parsing library makes it very convenient to deal with types whose json representation requires custom String transformations.
Let's try to accomplish the same thing using Codable:
extension RGBColor: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let stringValue = container.decode(String.self)
self.red = ???
self.green = ???
self.blue = ???
}
}
How do we get the rgb components from the decoded String? The Codable abstraction doesn't really provide a general answer to this. We could of course use the Parsing library here if we want:
extension RGBColor: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let stringValue = try container.decode(String.self)
self = try Parse(Self.init) {
Int.parser()
","
Int.parser()
","
Int.parser()
}
.parse(stringValue)
}
}
But it's not as seamlessly integrated into the rest of the code, as it was in the JSONParsing example, forcing us to manually call out to the parse
method for instance. And also, again, this is only half of the equation, we still have to deal with the encoding, which has to be implemented on its own.
Imagine that you are working with an api that delivers a list of ingredients in the following format:
let ingredientsJson = """
[
{
"name": "milk",
"amount": {
"value": 2,
"unit": "dl"
}
},
{
"name": "salt",
"amount": "a pinch"
}
]
""".data(using: .utf8)!
As you can see, the amount
can either be expressed as a combination of a value and a unit, or a string. In Swift, this is most naturally represented using an enum:
struct Ingredient {
enum Amount {
case exact(value: Int, unit: String)
case freetext(String)
}
let name: String
let amount: Amount
}
In this situation, we cannot get a suitable synthesized conformance to Codable
for the Amount
type, so we have no choice but to implement the methods ourselves. Let's do the Decodable
conformance:
extension Ingredient.Amount: Decodable {
enum CodingKeys: CodingKey {
case unit
case value
}
init(from decoder: Decoder) throws {
do {
let container = try decoder.singleValueContainer()
self = .freetext(try container.decode(String.self))
} catch {
let container = try decoder.container(keyedBy: CodingKeys.self)
let value = try container.decode(Int.self, forKey: .value)
let unit = try container.decode(String.self, forKey: .unit)
self = .exact(value: value, unit: unit)
}
}
}
For the Ingredient
type we can just use the automatically synthesized conformance:
extension Ingredient: Decodable {}
Now we can use a JSONDecoder
to decode the ingredientsJson
into a list of Ingredient
:
let ingredients = try JSONDecoder().decode([Ingredient].self, from: ingredientsJson)
print(ingredients)
// [Ingredient(name: "milk", amount: Ingredient.Amount.exact(value: 2, unit: "dl")), Ingredient(name: "salt", amount: Ingredient.Amount.freetext("a pinch"))]
So that works. We did have to create an explicit CodingKeys
type as well as two separate containers
for handling the two cases, which is a little bit of extra boilerplate, but it's not too bad. But there is actually a more fundamental problem here. To see that, let's modify the json input like this:
[
...
{
"name": "salt",
- "amount": "a pinch"
+ "amount": 3
}
]
""".data(using: .utf8)!
So the amount is now just a number, which is not allowed. When we try to decode the list, we get an error:
do {
let ingredients = try JSONDecoder().decode([Ingredient].self, from: ingredientsJson)
} catch {
print(error)
// typeMismatch(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [_JSONKey(stringValue: "Index 1", intValue: 1), CodingKeys(stringValue: "amount", intValue: nil)], debugDescription: "Expected to decode Dictionary<String, Any> but found a number instead.", underlyingError: nil))
}
The error message isn't very easily readable, but hidden in there is the message: "Expected to decode Dictionary<String, Any> but found a number instead."
. So judging by this error, it would seem like that the only valid type of value for the amount
field is a nested json object. But we know that there is actually a second valid option, namely a string. But this information got lost when the error was created, because of our (arbitrary) choice in the init(from:)
to first try to decode it as a string, and then if that fails, try the other alternative. If we had written it in the other order, our error message would instead have said "Expected to decode String but found a number instead."
. Either way, we are missing the fact that we have multiple valid choices.
So let's see how the JSONParsing library handles this kind of situation! Instead of conforming the types to Decodable
, let's write JSON parsers for them.
extension Ingredient.Amount {
static var jsonParser: some JSONParserPrinter<Self> {
OneOf {
ParsePrint(.case(Self.exact)) {
Field("value") { Int.jsonParser() }
Field("unit") { String.jsonParser() }
}
ParsePrint(.case(Self.freetext)) {
String.jsonParser()
}
}
}
}
extension Ingredient {
static var jsonParser: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
Field("name") { String.jsonParser() }
Field("amount") { Amount.jsonParser }
}
}
}
We make use of the OneOf
parser from the Parsing library, which will run a number of parsers until one succeeds, and if no one succeeds their errors are accumulated. Let's try decoding the same json as before, and see what is printed1:
do {
let ingredients = try JSONArray { Ingredient.jsonParser }.decode(ingredientsJson)
} catch {
print(error)
// At [index 1]/"amount":
// error: multiple failures occurred
//
// error: Expected an object (containing the key "value"), but found:
// 3
//
// Expected a string, but found:
// 3
}
As you can see, both possibilities are now mentioned in the printed error message. Also, as a bonus, the error message is a lot easier to read.
This also serves as a glimpse at what printed errors look like when using this library. They always have basically the same layout as what you see above: a path describing where something went wrong, and then a more detailed description of what went wrong. All in an easily readable format.
Another thing that I don't think is ideal with the Codable abstraction is that the decoding/encoding logic lives in two separate places. In part, it is implemented in the types when they conform to the two protocols, but then you can also control some of the behavior via properties on the JSONDecoder
/JSONEncoder
instance that you use to perform the decoding/encoding. For instance, the JSONDecoder
type has a keyDecodingStrategy
property that can be used to control how keys in the json objects are pre-processed during decoding, and a dateDecodingStrategy
that can be used to control how dates are decoded.
What this means is that a type's conformance to Decodable
/Encodable
is not a complete description of how that type is converted to/from json. To fully control how that happens, you also have to be in control over which JSONDecoder
/JSONEncoder
instance that is used.
When using JSONParsing, on the other hand, any json parser that you create, exactly determines how to transform a type to/from a json representation.
So far we have glossed over a detail of the library, that isn't immediately necessary to know about to start using it, but is useful to know about to understand how things work under the hood. Everywhere when we have created json parsers, we have given it the type of either some JSONParser<T>
or some JSONParserPrinter<T>
, and then when using them to decode or encode json data, we have used the decode(_:)
and encode(_:)
methods, respectively.
As it turns out, JSONParser<T>
and JSONParserPrinter<T>
are just typealiases for Parser<JSONValue, T>
and ParserPrinter<JSONValue, T>
, respectively (ParserPrinter
means it can both parse (decode) and print (encode), see the documentation for the Parsing library for more details).
So we are actually defining parsers that take as input a type called JSONValue
. This is a type exposed from this library, and just serves as a very basic typed representation of json, that looks like this:
public enum JSONValue: Equatable {
case null
case boolean(Bool)
case integer(Int)
case float(Double)
case string(String)
case array([JSONValue])
case object([String: JSONValue])
}
So when we call the decode(_:)
and encode(_:)
methods on the parsers, the decoding and encoding happens in two steps: the json data is transformed to/from the JSONValue
type, and the JSONValue
type is in turn transformed to/from the result type using the Parser.parse
/ParserPrinter.print
methods.
The primary use case for the JSONValue
type is just to act as this middle layer, to simplify the implementations of the various json parsers that ship with the library. However, it can actually be useful on its own. For instance, you might have code like this today:
let json: [String: Any] = [
"title": "hello",
"more_info": ["a": 1, "b": 2, ...],
...
]
let jsonData = try JSONSerialization.data(withJSONObject: json)
var request = URLRequest(url: requestUrl)
request.httpMethod = "POST"
request.httpBody = jsonData
While that does work, the fact that the json
has type [String: Any]
means that it could actually be a dictionary that holds any kind of data. In particular, it could hold data that isn't valid json data, and the compiler won't let you know. For instance, we could add a Date
in the title
field, and the compiler will be fine with it, but it will result in a runtime crash:
let json: [String: Any] = [
"title": Date(),
"more_info": ["a": 1, "b": 2, ...],
...
]
let jsonData = try JSONSerialization.data(withJSONObject: json)
// runtime crash: *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Invalid type in JSON write (__NSTaggedDate)'
By using the JSONValue
type instead in this scenario, you can get a compile time guarantee that your json data is valid. And thanks to the fact that JSONValue
conforms to a number of ExpressibleBy...
protocols, it can actually be initialized with the exact same syntax as before. So the previous example becomes:
let json: JSONValue = [
"title": "hello",
"more_info": ["a": 1, "b": 2, ...],
...
]
let jsonData = try json.toJsonData()
// ... the rest is the same
If we now try to replace "hello"
with Date()
as we did before, this time the compiler won't let us:
let json: [String: Any] = [
"title": Date(), // compiler error: Cannot convert value of type 'Date' to expected dictionary value type 'JSONValue'
"more_info": ["a": 1, "b": 2, ...],
...
]
This library ships with a number of json parsers, that can be composed together to deal with more complex json structures. As mentioned in the previous section, they all take values of the custom type JSONValue
as input, so when using the parse
/print
methods, they convert to/from that type.
When you want to use them to decode/encode json data (which is likely to be the most common use case) you just use the decode
/encode
methods defined on them instead, which does the converting to from data for you.
The Null
parser is used for parsing the special json value null
. You use it when you need to explicitly make sure that a value is null.
let nullJson: JSONValue = .null
let nonNullJson: JSONValue = 5.0
try Null().parse(nullJson)
// ()
try Null().parse(nonNullJson)
// throws:
// Expected a null value, but found:
// 5.0
When used as a printer (encoder), the Null
parser prints .null
:
try Null().print(()) // .null
The JSONBoolean
parser is used for parsing json booleans. It succeeds only when given either a false
or true
json value, and returns the corresponding Bool
value.
let booleanJson: JSONValue = false
let nonBooleanJson: JSONValue = [
"key1": 1,
"key2": "hello"
]
try JSONBoolean().parse(booleanJson)
// false
try JSONBoolean().parse(nonBooleanJson)
// throws:
// Expected a boolean, but found:
// {
// "key1": 1,
// "key2": "hello"
// }
An alternative way of constructing a JSONBoolean
parser, is via the static jsonParser()
method on the Bool
type:
try Bool.jsonParser().parse(booleanJson)
// false
The JSONBoolean
parser can also be used for printing (encoding) back into json:
try Bool.jsonParser().print(true)
// .boolean(true)
The JSONNumber
parser is used for parsing json numbers. Notable is the fact that the JSONValue
type has a separation between floating point numbers, and integer numbers. When using it to parse to a floating point type, the parser takes an optional parameter called allowInteger
, which controls whether it succeeds on integers as well as floating points. If not specified, that defaults to true
.
let integerJson: JSONValue = 10 // or .integer(10)
let floatJson: JSONValue = 2.4 // or .float(2.4)
let nonNumberJson: JSONValue = "hello"
try JSONNumber<Int>().parse(integerJson)
// 10
try JSONNumber<Double>().parse(floatJson)
// 2.4
try JSONNumber<Int>().parse(floatJson)
// throws:
// Expected an integer number, but found:
// 2.4
try JSONNumber<Double>().parse(integerJson)
// 10.0
try JSONNumber<Double>(allowInteger: false).parse(integerJson)
// throws:
// Expected a floating point number, but found:
// 10
try JSONNumber<Double>().parse(nonNumberJson)
// throws:
// Expected a number, but found:
// "hello"
Alternatively, a JSONNumber
parser can be constructed via the jsonParser()
static methods defined on BinaryInteger
and BinaryFloatingPoint
:
try Int.jsonParser().parse(integerJson) // 10
try Int64.jsonParser().parse(integerJson) // 10
try Double.jsonParser().parse(floatJson) // 2.4
try CGFloat.jsonParser(allowInteger: false).parse(floatJson) // 2.4
Note: when decoding json data, using the decode
method, a number in the json object is interpreted as a floating point if it has any decimals (including just a 0
).
let json = """
{
"a": 10,
"b": 10.5,
"c": 10.0
}
""".data(using: .utf8)!
try Field("a") { Int.jsonParser() }.decode(json)
// 10
try Field("b") { Int.jsonParser() }.decode(json)
// throws:
// At "b":
// Expected an integer number, but found:
// 10.5
try Field("c") { Int.jsonParser() }.decode(json)
// throws:
// At "c":
// Expected an integer number, but found:
// 10.0
try Field("b") { Double.jsonParser() }.decode(json)
// 10.5
try Field("c") { CGFloat.jsonParser() }.decode(json)
// 10.0
The JSONNumber
parser can also be used for printing to json:
try Int.jsonParser().print(25) // .integer(25)
try Double.jsonParser().print(1.6) // .float(1.6)
The JSONString
parser is used for parsing json strings. And as has been showed in previous sections, it can also be given a string parser, for performing custom parsing of the string value.
let stringJson: JSONValue = "120,200,43"
let nonStringJson: JSONValue = [1, 2, 3]
try JSONString().parse(stringJson)
// "120,200,43"
try JSONString().parse(nonStringJson)
// throws:
// Expected a string, but found:
// [ 1, 2, 3 ]
try JSONString {
Int.parser()
","
Int.parser()
","
Int.parser()
}.parse(stringJson)
// (120, 200, 43)
let nonMatchingStringJson: JSONValue = "apple"
try JSONString {
Int.parser()
","
Int.parser()
","
Int.parser()
}.parse(stringJson)
// throws:
// error: unexpected input
// --> input:1:1
// 1 | apple
// | ^ expected integer
There is also a version of the initializer that takes a string conversion. A conversion is a concept introduced in the Parsing library, and works like a two-way function. The library also exposes a number of predefined conversions, for example the representing(_:)
conversion, that can be used to convert between RawRepresentable
types, and their raw values. Using it with the JSONString
parser looks like this:
enum Direction: String {
case up, down, left, right
}
extension Direction {
static let jsonParser = JSONString(.representing(Direction.self))
}
let json: JSONValue = "left"
let direction = Direction.jsonParser.parse(json)
print(direction) // Direction.left
try Direction.jsonParser.print(direction)
// .string("left")
When you don't need any custom parsing, and just want to parse a json string as it is, you can also choose to define the parser with the static jsonParser()
method defined on the String
type:
let json: JSONValue = "hello"
try String.jsonParser().parse(json)
// "hello"
The JSONString
can be used as a printer, to print (decode) to json, as long as the underlying string parser given to it is a printer itself.
try JSONString {
Int.parser()
","
Int.parser()
","
Int.parser()
}.print((120, 200, 43))
// .string("120,200,43")
The JSONArray
parser is used for parsing json arrays. You construct it by providing a parser that should be applied to each element of the array. As a bonus you can also, optionally, specify that the array must be of a certain size, by giving it a range or a single number. It looks like this to use it for parsing json:
let directionArrayJson: JSONValue = ["left", "left", "right", "up"]
let numberArrayJson: JSONValue = [1, 2, 3]
let nonArrayJson: JSONValue = 10.5
try JSONArray {
Direction.jsonParser
}.parse(directionArrayJson)
// [Direction.left, Direction.left, Direction.right, Direction.up]
try JSONArray(1...3) {
Direction.jsonParser
}.parse(directionArrayJson)
// throws:
// Expected 1-3 elements in array, but found 4.
try JSONArray(3) {
Direction.jsonParser
}.parse(directionArrayJson)
// throws:
// Expected 3 elements in array, but found 4.
try JSONArray {
Direction.jsonParser
}.parse(numberArrayJson)
// throws:
// At [index 0]:
// Expected a string, but found:
// 1
try JSONArray {
Int.jsonParser()
}.parse(numberArrayJson)
// [1, 2, 3]
try JSONArray {
Int.jsonParser()
}.parse(nonArrayJson)
// throws:
// Expected an array, but found:
// 10.5
And for printing (which is available whenever the element parser given to it has printing capabilities):
try JSONArray {
Direction.jsonParser
}.print([Direction.right, .left, .down])
// .array(["right", "left", "down"])
The JSONObject
parser is used to parse a json object into a dictionary. In it's most basic form it takes a single Value
parser, to be applied to each value in the json object. And the result after parsing will be a [String: Value.Output]
dictionary, where Value.Output
is the type returned from the Value
parser.
let objectJson: JSONValue = .object([
"url1": "https://www.example.com/1",
"url2": "https://www.example.com/2",
"url3": "https://www.example.com/3",
])
let dictionary = try JSONObject {
URL.jsonParser()
}.parse(objectJson)
print(dictionary)
// ["url1": https://www.example.com/1, "url3": https://www.example.com/3, "url2": https://www.example.com/2]
try JSONObject {
URL.jsonParser()
}.print(dictionary)
// .object(["url1": "https://www.example.com/1", "url3": "https://www.example.com/3", "url2": "https://www.example.com/2"])
But you can also specify custom parsing of the keys into any Hashable
type, by adding on a keys
parser parameter:
let objectJson: JSONValue = [
"key_1": "Steve Jobs",
"key_2": "Tim Cook"
]
let dictionary = try JSONObject {
String.jsonParser()
} keys: {
"key_"
Int.parser()
}.parse(objectJson)
print(dictionary)
// [1: "Steve Jobs", 2: "Tim Cook"]
try JSONObject {
String.jsonParser()
} keys: {
"key_"
Int.parser()
}.print(dictionary)
// .object(["key_1": "Steve Jobs", "key_2": "Tim Cook"])
or by passing a string conversion to the initializer, for example a representing
conversion to turn the keys into some RawRepresentable
type:
struct UserID: RawRepresentable, Hashable {
var rawValue: String
}
let usersJson: JSONValue = .object([
"abc": "user 1",
"def": "user 2",
])
let dictionary = try JSONObject(keys: .representing(UserID.self)) {
String.jsonParser()
}.parse(usersJson)
print(dictionary)
// [UserID(rawValue: "abc"): "user 1", UserID(rawValue: "def"): "user 2"]
try JSONObject(keys: .representing(UserID.self)) {
String.jsonParser()
}.print(dictionary)
// .object(["abc": "user 1", "def": "user 2"])
And just like the JSONArray
parser, it can be restricted to only accept a certain number of elements (key/value pairs).
let emptyObjectJson: JSONValue = [:]
try JSONObject(1...) {
URL.jsonParser()
}.parse(emptyObjectJson)
// throws: Expected at least 1 key/value pair in object, but found 0.
let emptyDictionary: [String: URL] = [:]
try JSONObject(1...) {
URL.jsonParser()
}.print(emptyDictionary)
// throws: An JSONObject parser requiring at least 1 key/value pair was given 0 to print.
The Field
parser is used for parsing a single value at a given field. It takes as input a key, as a String
, and a json parser to be applied to the value found at that key.
let personJson: JSONValue = [
"first_name": "Steve",
"last_name": "Jobs",
"age": 56,
]
let personJsonWithoutFirstName: JSONValue = [
"last_name": "Cook",
"age": 62,
]
try Field("first_name") {
String.jsonParser()
}.parse(personJson)
// "Steve"
try Field("first_name") {
String.jsonParser()
}.print("Steve")
// .object(["first_name": "Steve"])
try Field("first_name") {
Int.jsonParser()
}.parse(personJson)
// throws:
// At "first_name":
// Expected an integer number, but found:
// "Steve"
try Field("first_name") {
String.jsonParser()
}.parse(personJsonWithoutFirstName)
// throws:
// Key "first_name" not present.
Most often, you will probably want to combine multiple Field
parsers together, to parse to a more complex result type. For the example above, you will likely have a Person
type that you want to turn the json into. For that, we can make use of the memberwise
conversion exposed from the Parsing library.
struct Person {
let firstName: String
let lastName: String
let age: Int
}
extension Person {
static var jsonParser: some JSONParserPrinter<Self> {
try ParsePrint(.memberwise(Person.init)) {
Field("first_name") { String.jsonParser() }
Field("last_name") { String.jsonParser() }
Field("age") { Int.jsonParser() }
}
}
}
let person = try Person.jsonParser.parse(personJson)
// Person(firstName: "Steve", lastName: "Jobs", age: 56)
try Person.jsonParser.print(person)
// .object(["first_name": "Steve", "last_name": "Jobs", "age": 56])
The OptionalField
parser works like the Field
parser, but it allows for the field to not exist (or be null
). To see what that is useful for, let's extend the Person
type with a new field called salary
:
struct Person {
let firstName: String
let lastName: String
let age: Int
+ let salary: Double?
}
Then we can extend the Person.jsonParser
in the following way:
try ParsePrint(.memberwise(Person.init)) {
Field("first_name") { String.jsonParser() }
Field("last_name") { String.jsonParser() }
Field("age") { Int.jsonParser() }
+ OptionalField("salary") { Double.jsonParser() }
}
Now it can handle person json values with or without a salary.
let personJsonWithSalary: JSONValue = [
"first_name": "Bob",
"last_name": "Bobson",
"age": 50,
"salary": 12000
]
let personJsonWithoutSalary: JSONValue = [
"first_name": "Mark",
"last_name": "Markson",
"age": 20
]
let person1 = try Person.jsonParser.parse(personJsonWithSalary)
// Person(firstName: "Bob", lastName: "Bobson", age: 50, salary: 12000.0)
try Person.jsonParser.print(person1)
// .object(["first_name": "Bob", "last_name": "Bobson", "age": 50, "salary": 12000.0])
let person2 = try Person.jsonParser.parse(personJsonWithoutSalary)
// Person(firstName: "Mark", lastName: "Markson", age: 20, salary: nil)
try Person.jsonParser.print(person2)
// .object(["first_name": "Mark", "last_name": "Markson", "age": 20])
Instead of treating an absent value as nil
, you can optionally provide a default
value, to use as a fallback:
struct Person {
let firstName: String
let lastName: String
let age: Int
- let salary: Double?
+ let salary: Double
}
extension Person {
static var jsonParser: some JSONParserPrinter<Self> {
try ParsePrint(.memberwise(Person.init)) {
Field("first_name") { String.jsonParser() }
Field("last_name") { String.jsonParser() }
Field("age") { Int.jsonParser() }
- OptionalField("salary") { Double.jsonParser() }
+ OptionalField("salary", default: 0) { Double.jsonParser() }
}
}
}
Now, parsing a person json without a salary, will use the default value of 0
:
let person = try Person.jsonParser.parse(personJsonWithoutSalary)
// Person(firstName: "Mark", lastName: "Markson", age: 20, salary: 0)
try Person.jsonParser.print(person)
// .object(["first_name": "Mark", "last_name": "Markson", "age": 20])
While this library is intended to be able to stand on its own as a fully featured alternative to Codable, it does come with tools to help bridge these two worlds, allowing them to be mixed together. This is important partly because you may be working with other libraries that force you to use Codable in some places, and partly because it allows you to transition a code base that uses Codable, one model at a time. Let's take a look at how it works.
Imagine that you have the following type:
struct Person {
let name: String
let age: Int
let favoriteMovie: Movie?
}
where the Movie
type is Codable
, and you want to create a json parser for Person
. For situations like this, the library extends all Decodable
types with a jsonParser(decoder:)
method, that takes an optional JSONDecoder
parameter. And if the type also conforms to Encodable
, the method takes an optional JSONEncoder
parameter as well. So for our example, we can make use of this in the parse implementation, to deal with the Movie
type:
extension Person {
static var jsonParser: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
Field("name") { String.jsonParser() }
Field("age") { Int.jsonParser() }
Field("favorite_movie") { Movie.jsonParser() }
}
}
}
and if we need to customize the decoding/encoding of the Movie
type, we can pass a custom decoder and/or encoder like this:
let jsonDecoder: JSONDecoder = ...
let jsonEncoder: JSONEncoder = ...
extension Person {
static var jsonParser: some JSONParserPrinter<Self> {
ParsePrint(.memberwise(Self.init)) {
...
Field("favoriteMovie") { Movie.jsonParser(decoder: jsonDecoder, encoder: jsonEncoder) }
}
}
}
So that's one part of the equation, when it comes to integration with Codable. But what about the other way around? What if we actually do have a json parser capable of decoding Movie
s, and we're using Codable for the Person
type instead. For that use case, the library comes with overloads of the various methods on the decoding/encoding containers, that take a json parser as input. Let's see what it looks like to use this, by conforming the Person
type to both the Decodable
and the Encodable
protocol:
extension Person: Decodable {
enum CodingKeys: String, CodingKey {
case name
case age
case favoriteMovie = "favorite_movie"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
self.age = try container.decode(Int.self, forKey: .age)
self.favoriteMovie = try container.decodeIfPresent(forKey: .favoriteMovie) {
Movie.jsonParser
}
}
}
extension Person: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.name, forKey: .name)
try container.encode(self.age, forKey: .age)
try container.encodeIfPresent(self.favoriteMovie, forKey: .favoriteMovie) {
Movie.jsonParser
}
}
}
Here, we make use of the overloads of the KeyedDecodingContainer.decodeIfPresent
, and KeyedEncodingContainer.encodeIfPresent
methods, that takes a json parser as input. Apart from taking an extra json parser parameter, the decoding overloads also make the type
parameter optional, since it can always be inferred anyway. But if you want, you can still explicitly specify them like for the default versions:
extension Person: Decodable {
...
init(from decoder: Decoder) throws {
...
- self.favoriteMovie = try container.decodeIfPresent(forKey: .favoriteMovie) {
+ self.favoriteMovie = try container.decodeIfPresent(Movie.self, forKey: .favoriteMovie) {
Movie.jsonParser
}
}
}
This library comes with a few benchmarks, comparing the execution time for decoding and encoding with that of the corresponding Codable implementation.
MacBook Pro (14-inch, 2021)
Apple M1 Pro (10 cores, 8 performance and 2 efficiency)
16 GB (LPDDR5)
name time std iterations
-----------------------------------------------------------------------------
Decoding.JSONDecoder (Codable) 174917.000 ns ± 3.19 % 7610
Decoding.JSONParser 169625.000 ns ± 2.20 % 8070
Decoding.JSONParser (mixed with Codable) 311250.000 ns ± 8.36 % 4467
Decoding.JSONParser (from JSONValue) 67042.000 ns ± 2.06 % 20820
Encoding.JSONEncoder (Codable) 1212416.500 ns ± 0.96 % 1144
Encoding.JSONParser 2082541.000 ns ± 22.11 % 680
Encoding.JSONParser (mixed with Codable) 2889500.000 ns ± 23.28 % 465
Encoding.JSONParser (to JSONValue) 397417.000 ns ± 1.09 % 3499
You can add the library as a dependency using SPM by adding the following to the Package.swift
file:
dependencies: [
.package(url: "https://github.com/oskarek/swift-json-parsing", from: "0.2.0"),
]
and then in each module that needs access to it:
.target(
name: "MyModule",
dependencies: [
.product(name: "JSONParsing", package: "swift-json-parsing"),
]
),
This library is released under the MIT license. See LICENSE for details.
Footnotes
-
At the time of writing, this is actually a slight lie. In this exact situation, the first line
At [index 1]/"amount":
would in fact be split across two lines readingAt [index 1]:
anderror: At "amount":
respectively. This is due to a current limitation preventing the error path to be printed in the ideal way, that will hopefully be fixed in the near future. In many other situations though, the error path will be printed in that nice compact format, so I still wanted to show that version. ↩