Encoding and Decoding
Converting between your domain models and JSON is such a common task when building useful programs, every language under the sun is saturated with libraries that have their own flavor of filling the developer need. Each undoubtedly comes with it's own set of pros and cons. Hopefully this page will convince you that Ribs JSON brings enough utility to be considered amongst other solutions in the Dart ecosystem.
Decoder
For most cases, defining a Codec
for your domain models is where you'll
find the most utility from the Ribs JSON library. However, it's worth knowing
how Decoder
and Encoder
work since Codec
is essentially a composition of
those two types.
Let's quickly go through a quick scenario to introduce how the API works:
final class User {
final String name;
final int age;
final IList<Pet> pets;
const User(this.name, this.age, this.pets);
}
final class Pet {
final String name;
final Option<double> weight;
final PetType type;
const Pet(this.name, this.weight, this.type);
}
enum PetType { mammal, reptile, other }
Now that we know what models we need to convert, it's time to create a few Decoder
s for
each type:
final petTypeDecoder = Decoder.integer.map((n) => PetType.values[n]);
final petDecoder = Decoder.product3(
Decoder.string.at('name'),
Decoder.dubble.optional().at('weight'),
petTypeDecoder.at('type'),
(name, weight, type) => Pet(name, weight, type),
);
final userDecoder = Decoder.product3(
Decoder.string.at('name'),
Decoder.integer.at('age'),
Decoder.ilist(petDecoder).at('pets'),
(name, age, pets) => User(name, age, pets),
);
const jsonStr = '''
{
"name": "Joe",
"age": 20,
"pets": [
{ "name": "Ribs", "weight": 22.1, "type": 0 },
{ "name": "Lizzy", "type": 11 }
]
}
''';
print(Json.decode(jsonStr, userDecoder));
// Right(Instance of 'User')
There's a bit to take in here so we can break it down into a few pieces an analyze them one at a time:
The first Decoder
we define is the petTypeDecoder
which expects to find an
int
and proceeds to map
that to the value at PetType.values[n]
. We'll
revisit this particular Decoder later since this implementation isn't entirely
correct.
Next, the petDecoder
uses the Decoder.product3
function, which expects 3
Decoders
that will each handle decoding one value, as well as a function that
will decide how those values are combined. In this case, we have:
- A decoder that expects to find a string value at the key
name
. - A decoder that expects to find an optional double (or nullable) value at the
key
weight
. - The
petTypeDecoder
we already defined expects to find an int value at the keytype
which will then bemap
ped to aPetType
. - A function that declares the 3 values should be combined to create a new
instance of
User
.
Since the input is valid in this small example, a new instance of Right(User)
is what we end up with.
Error handling
Now let's revist the definition of our petTypeDecoder
above. It has a fatal
flaw which we can address using the Decoder
API. Consider the following
example:
final petTypeDecoder = Decoder.integer.map((n) => PetType.values[n]);
print(Json.decode('0', petTypeDecoder)); // Right(PetType.mammal)
print(Json.decode('1', petTypeDecoder)); // Right(PetType.reptile)
print(Json.decode('2', petTypeDecoder)); // Right(PetType.other)
print(Json.decode('100', petTypeDecoder)); // ???
The last call to Json.decode
throws an exception! This is because the integer
value (100) doesn't correspond to a valid PetType
enum value. What is to be
done? The best first step is to consult the API to see what combinators are
availble on Decoder
to handle such scenarios. Here's an improved solution:
// Solution using .emap
final petTypeDecoderA = Decoder.integer.emap(
(n) => Either.cond(() => 0 <= n && n < PetType.values.length,
() => PetType.values[n], () => 'Invalid value index for PetType: $n'),
);
print(Json.decode('100', petTypeDecoderA));
// Left(DecodingFailure(CustomReason(Invalid PetType index: 100), None))
Decoder.emap
allows you to map a value but provide a function that return
an Either<String, A>>
which allows you the developer to determine how
errors during decoding should be handled.
Finally, encoding and decoding enum
s is so common that the API provides a
Decoder
, Encoder
and Codec
specifically designed for them:
// Uses Enum.index to look up instance
final petTypeDecoderByIndex = Decoder.enumerationByIndex(PetType.values);
// Uses Enum.name to look up instance
final petTypeDecoderByName = Decoder.enumerationByName(PetType.values);
Decoder.emap
only scratches the surface of how you can customize a Decoders
behavior. Browse the API to see what else is available!
Encoder
Next in line is seeing how we can use the Encoder
class to convert our domain
models into Json
. The strategy is similar to the one we took with our decoders.
build one for each data type, and pies them together to build a more elaborate and
complex Encoder
.
final petTypeEncoder = Encoder.instance((PetType t) => Json.number(t.index));
// Alternative PetType solution
final petTypeEncoderAlt = Encoder.integer.contramap<PetType>((t) => t.index);
final petEncoder = Encoder.instance(
(Pet p) => Json.obj(
[
('name', Json.str(p.name)),
('weight', p.weight.fold(() => Json.Null, (w) => Json.number(w))),
('type', petTypeEncoder.encode(p.type)),
],
),
);
final userEncoder = Encoder.instance(
(User u) => Json.obj(
[
('name', Json.str(u.name)),
('age', Json.number(u.age)),
('pets', Json.arrI(u.pets.map(petEncoder.encode))),
],
),
);
print(
userEncoder
.encode(
User(
'Henry',
40,
ilist([
const Pet('Ribs', Some(22.0), PetType.mammal),
const Pet('Lizzy', None(), PetType.reptile),
]),
),
)
.printWith(Printer.spaces2),
);
// {
// "name" : "Henry",
// "age" : 40,
// "pets" : [
// {
// "name" : "Ribs",
// "weight" : 22.0,
// "type" : 0
// },
// {
// "name" : "Lizzy",
// "weight" : null,
// "type" : 1
// }
// ]
// }
Codec
While what we've done so far is effective and composable, an argument can be made that
it's pretty verbose. We can cut down on that issue quite a bit by combining Decoder
s
and Encoder
s in form of a Codec
!
Usually a Codec
is what you'll want to use when writing your
(de)serialization code but sometimes you'll want or need a little more control
which is why it's still useful to be familiar with Decoder
and Encoder
.
Let's use Codec
to rewrite the previous Decoder
and Encoder
's:
final petTypeCodec = Codec.integer.iemap(
(n) => Either.cond(
() => 0 <= n && n < PetType.values.length,
() => PetType.values[n],
() => 'Invalid PetType index: $n',
),
(petType) => petType.index,
);
final petCodec = Codec.product3(
'name'.as(Codec.string), // Same as: Codec.string.atField('name'),
'weight'.as(Codec.dubble).optional(),
'type'.as(petTypeCodec),
Pet.new,
(p) => (p.name, p.weight, p.type),
);
final userCodec = Codec.product3(
'name'.as(Codec.string),
'age'.as(Codec.integer),
'pets'.as(Codec.ilist(petCodec)),
User.new, // Constructor tear-off
(u) => (u.name, u.age, u.pets), // Describe how to turn user into tuple
);
const jsonStr = '''
{
"name": "Joe",
"age": 20,
"pets": [
{ "name": "Ribs", "weight": 22.1, "type": 0 },
{ "name": "Lizzy", "type": 11 }
]
}
''';
// Codecs can decode and encode
final user = Json.decode(jsonStr, userCodec);
final str = userCodec.encode(const User('name', 20, Nil()));
This example uses the new form of "string".as(Codec.integer)
which is just
syntax equivalent to what we used before in our Encoder
definitions. The given
Codec
is used to decode the value at the given key.