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 Decoders 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 
petTypeDecoderwe already defined expects to find an int value at the keytypewhich will then bemapped 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 enums 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 Decoders
and Encoders 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.