Skip to main content

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:

Domain Model
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 Decoders for each type:

Decoders
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 key type which will then be mapped to a PetType.
  • 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:

Decoder Error
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:

Decoder.emap
// 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 enums is so common that the API provides a Decoder, Encoder and Codec specifically designed for them:

Decoder.enumeration
// 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);
tip

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.

Model Encoders
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 Decoders and Encoders in form of a Codec!

info

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:

Model Codecs
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.