A universal serialization and deserialization library for the C3 programming language.
Dessert provides a flexible, type-safe framework for converting C3 structs to and from various formats (e.g., JSON). It uses compile-time macros to generate serialization/deserialization code, ensuring type safety and minimal runtime overhead.
The library is built around two core interfaces:
- Serializer: Converts C3 structs into a target format
- Deserializer: Parses data from a format back into C3 structs
- Recursive struct serialization (nested structs)
- Support for
Maybefields (optional values) - Support for slice/array/List fields
- Enum serialization (as name, ordinal, or associated field)
- Tagged union serialization (named, anonymous, and inlined patterns)
- Field flattening (inline a nested struct's fields into the parent)
- Skip specific fields during serialization
- Skip empty fields (slices,
Maybe, pointers) viaskip_if_empty - Conditionally skip fields via
skip_serializing_<field>methods - Rename fields for the output
- Bulk field rename via
rename_allcase conventions - Validate field values before serialization
- Support for all primitive types:
bool,char,ichar,short,int,long,int128,ushort,uint,ulong,uint128,float,double,String,ZString
- Handle nested structures
- Support for
Maybefields - Support for slice/array/List fields
- Enum deserialization (as name, ordinal, or associated field)
- Enum fallback variant for unknown values via
.fallback - Tagged union deserialization (named, anonymous, and inlined patterns)
- Field renaming support (different name in the format and in C3)
- Duplicate key detection
- Unknown field skipping (or rejection via
deny_unknown_fields) - Deserialize arbitrary data into
Objectfields - Descriptive error messages on deserialization failure
- Support for all primitive types:
bool,char,ichar,short,int,long,int128,ushort,uint,ulong,uint128,float,double,String,ZString
The dessert::json module provides a complete JSON serializer and deserializer.
-
json::serializerto get a serializer that produces a JsonValue (with methods liketo_pretty_string()). Accepts an optional allocator:json::serializer(allocator)(defaults totmem). -
json::string_serializerto get a serializer that produces directly a json string. -
json::tdeserializerto get a deserializer that takes a json string and produces your struct (usestmem). -
json::deserializerto get a deserializer with a custom allocator:json::deserializer(allocator, json_str).
The dessert::csv module provides a CSV serializer for converting slices of structs into CSV format. Only serialization is supported for now (no CSV deserializer).
Use the @DField attribute to customize serialization/deserialization behavior for struct fields:
struct Person {
int age; // Serialized as "age"
String name; // Serialized as "name"
bool is_cool @DFieldSer({ .skip = true }); // Skipped during serialization
Maybe{Person*} friend @DField({ .rename = "my_friend" }); // Renamed to "my_friend"
int score @DField({ .validator = "validate_score" }); // Validated before serialization
}Field attributes:
| Attribute | Description |
|---|---|
@DField |
Apply config to both serialization and deserialization |
@DFieldSer |
Apply config to serialization only |
@DFieldDes |
Apply config to deserialization only |
Field attribute options (DFieldConfig struct):
| Option | Type | Description |
|---|---|---|
.skip |
bool |
Skip this field during serialization/deserialization |
.skip_if_empty |
bool |
Skip this field during serialization if it is empty (null pointer, empty slice, or unset Maybe) |
.rename |
String |
Use a different name for this field in the output/input |
.aliases |
String[] |
Alternative names to accept during deserialization (not yet implemented) |
.validator |
String |
Call a validation method before serialization |
.flatten |
bool |
Flatten a nested struct's fields directly into the parent object |
.tagged |
DFieldUnionConfig |
Tagged union configuration (see Union attributes below) |
Conditional skip methods:
If a struct has a method named skip_serializing_<field> that returns bool, dessert will call it before serializing that field and skip the field if it returns true:
struct Person {
int age;
String name;
}
fn bool Person.skip_serializing_age(&self) {
return self.age == 0; // don't serialize age when it's 0
}Enum attributes:
Use @DEnum (or @DEnumSer / @DEnumDes) on an enum type to control how it is serialized:
enum Color @DEnum({ .as = DESCRIPTION }) {
RED,
GREEN,
BLUE,
}| Attribute | Description |
|---|---|
@DEnum |
Apply enum config to both serialization and deserialization |
@DEnumSer |
Apply enum config to serialization only |
@DEnumDes |
Apply enum config to deserialization only |
Enum attribute options (DEnumConfig struct):
| Option | Type | Description |
|---|---|---|
.as |
DessertEnum |
How to represent the enum: DESCRIPTION (default), ORDINAL, or FIELD |
.field |
String |
Name of the associated field to use when .as = FIELD |
.fallback |
String |
Name of the variant to use when the JSON value doesn't match any known variant (deserialization only). If not set, INVALID_ENUM_VALUE is raised instead. |
// Unknown JSON variant → UNKNOWN instead of raising INVALID_ENUM_VALUE
enum Status @DEnumDes({ .fallback = "UNKNOWN" }) {
ACTIVE,
INACTIVE,
UNKNOWN,
}
// Unknown field value → NONE instead of raising INVALID_ENUM_VALUE
enum Priority : (String code) @DEnumDes({ .as = FIELD, .field = "code", .fallback = "NONE" }) {
HIGH { "high" },
MEDIUM { "medium" },
NONE { "" },
}Struct attributes:
Use @DStruct (or @DStructSer / @DStructDes) on a struct type to control struct-level behavior:
struct Function @DStruct({ .deny_unknown_fields = true }) {
String name;
String arguments;
}| Attribute | Description |
|---|---|
@DStruct |
Apply struct config to both serialization and deserialization |
@DStructSer |
Apply struct config to serialization only |
@DStructDes |
Apply struct config to deserialization only |
Struct attribute options (DStructConfig struct):
| Option | Type | Description |
|---|---|---|
.deny_unknown_fields |
bool |
Return UNKNOWN_FIELD fault if an unrecognized field is encountered during deserialization (default: skip unknown fields silently) |
.rename_all |
CaseConvention |
Rename all fields using a naming convention |
CaseConvention values:
| Value | Example output |
|---|---|
VERBATIM |
verbatim |
KEBAB_CASE |
kebab-case |
CAMEL_CASE |
camelCase |
PASCAL_CASE |
PascalCase |
SCREAMING_SNAKE_CASE |
SCREAMING_SNAKE |
SNAKE_CASE |
snake_case |
LOWER_CASE |
lower |
UPPER_CASE |
UPPER |
struct UserRecord @DStructSer({ .rename_all = CAMEL_CASE }) {
String first_name; // serialized as "firstName"
String last_name; // serialized as "lastName"
int birth_year; // serialized as "birthYear"
}Union attributes:
Use @DField({ .tagged = { .by = "field" } }) on a union member to enable tagged dispatch — the value of the named sibling field determines which union member is active. All union configuration lives in the tagged sub-struct of @DField.
DFieldUnionConfig options (used as .tagged = { ... }):
| Option | Type | Description |
|---|---|---|
.by |
String |
Name of the sibling field whose value selects the active union member (required) |
.inlined |
bool |
Inline the active member's value directly (no wrapping object, false by default) |
.match |
DFieldUnionMatch |
How to match the tag value to a union member (see below) |
.unmapped |
DFieldUnionUnmapped |
What to do when the tag value matches no union member (default: ERROR) |
DFieldUnionMatch options (used as .tagged = { .match = { ... } }):
| Option | Type | Description |
|---|---|---|
.by |
DessertEnum |
How to match: ORDINAL (default for non-enum tags), DESCRIPTION, or FIELD |
.field |
String |
Associated enum field name (a String); required when .by = FIELD |
match.by modes:
| Value | Behavior |
|---|---|
ORDINAL |
Cast tag to sz; select member by 0-based index. Used automatically for non-enum tags. |
DESCRIPTION |
Switch on the enum value itself; union member names uppercased must match enum value names. |
FIELD |
Switch on tag.<field> (a String); value must equal the union member name exactly. |
DFieldUnionUnmapped options (used as .tagged = { .unmapped.as = ... }):
| Option | Type | Serialize behavior | Deserialize behavior |
|---|---|---|---|
.as = ERROR |
(default) | Raise UNMAPPED_UNION_VARIANT |
Raise UNMAPPED_UNION_VARIANT |
.as = EMPTY_STRUCT |
Write {} (empty object) |
Consume next value as Object* |
|
.as = NULL |
Write null |
Consume next value as Maybe{int} |
|
.as = ZERO |
Write 0 |
Consume next value as int |
Five output patterns:
Pattern A — named union field, not inlined (active member wrapped in a nested object):
$expand(derive(Message::name, dessert));
struct Message {
int kind;
union payload @DField({ .tagged.by = "kind" }) {
int count;
double ratio;
String text;
}
}
// kind=0 → {"kind":0,"payload":{"count":42}}
// kind=2 → {"kind":2,"payload":{"text":"hi"}}Pattern B — anonymous union field (active member flattened into parent):
$expand(derive(Message::name, dessert));
struct Message {
int kind;
union @DField({ .tagged.by = "kind" }) {
int count;
double ratio;
String text;
}
}
// kind=0 → {"kind":0,"count":42}
// kind=2 → {"kind":2,"text":"hi"}Pattern C — named union field with .inlined = true (active value inlined, no wrapping object):
union Variant {
int count;
struct point { int x; int y; }
String label;
}
$expand(derive(Response::name, dessert));
struct Response {
int tag;
Variant val @DField({ .tagged = { .by = "tag", .inlined = true } });
}
// tag=0 → {"tag":0,"val":42}
// tag=1 → {"tag":1,"val":{"x":1,"y":2}}Pattern D — enum tag with match.by = DESCRIPTION (match by enum value name):
$expand(derive(Shape::name, dessert));
enum Shape { CIRCLE, SQUARE, TRIANGLE }
$expand(derive(Drawing::name, dessert));
struct Drawing {
Shape kind;
union payload @DField({ .tagged = { .by = "kind", .inlined = true, .match.by = DESCRIPTION } }) {
int circle; // matched by enum value CIRCLE
double square; // matched by enum value SQUARE
String triangle; // matched by enum value TRIANGLE
}
}Pattern E — enum tag with match.by = FIELD (match by enum associated String field):
$expand(derive(Format::name, dessert));
enum Format : (String mime) {
JSON_FMT { "json_fmt" },
CSV_FMT { "csv_fmt" },
}
$expand(derive(Output::name, dessert));
struct Output {
Format kind;
union payload @DField({ .tagged = { .by = "kind", .inlined = true, .match = { .by = FIELD, .field = "mime" } } }) {
int json_fmt; // matched when kind.mime == "json_fmt"
String csv_fmt; // matched when kind.mime == "csv_fmt"
}
}Using unmapped.as to handle unknown tag values gracefully:
$expand(derive(Request::name, dessert));
struct Request {
int kind;
// Out-of-range kind consumed as null instead of raising a fault
union payload @DField({ .tagged = { .by = "kind", .inlined = true, .unmapped.as = NULL } }) {
int ping;
String text;
}
}Note: when deserializing an inlined union field, the tag field must appear before the union field in the input.
Using c3po;
In your project, run
c3po add ecoral360/dessertGet started with dessert:
- Make sure you have the C3 compiler installed
- Run
c3c init <YOUR_PROJECT> - Clone the this repository into
<YOUR_PROJECT>/lib/dessert.c3l - Add
"dependencies": ["dessert"]to yourproject.json - You are done !
Use $expand(derive(...)) to automatically generate serialize and deserialize methods:
import derive;
$expand(derive(Animal::name, dessert));
struct Animal {
String name;
String specie;
}dessert derives both methods. Use dessert::serialize or dessert::deserialize to derive only one:
$expand(derive(Animal::name, dessert::serialize)); // serialize only
$expand(derive(Animal::name, dessert::deserialize)); // deserialize onlyAnimal sharpie = { .specie = "Cat", .name = "Sharpie" };
String json = ser::serialize(&&json::string_serializer(), sharpie)!!;Animal[] animals = {
{ .name = "Sharpie", .specie = "Cat" },
{ .name = "Rex", .specie = "Dog" },
};
CSVSerializer s = csv::serializer();
CSVDocument doc = ser::serialize(&s, animals)!;
io::printn(doc.to_string());Output:
"name","specie"
"Sharpie","Cat"
"Rex","Dog"
String json_str = "{\"name\": \"Sharpie\", \"specie\": \"Cat\"}";
Animal? animal = des::deserialize{Animal}(&&json::tdeserializer(json_str));module example;
import std;
import dessert;
import json;
import derive;
$expand(derive(Animal::name, dessert));
struct Animal {
String name;
String specie;
}
$expand(derive(Person::name, dessert));
struct Person {
int age;
String name;
Animal[] pets;
Maybe{Person*} friend @DField({ .rename = "my_friend" });
bool is_cool @DField({ .skip = true });
}
fn int main(String[] args) {
Animal sharpie = { .name = "Sharpie", .specie = "Cat" };
Person connor = { .age = 20, .name = "Connor", .is_cool = true };
connor.pets = { sharpie };
String json = ser::serialize(&&json::string_serializer(), connor)!!;
io::printn(json);
Person? p = des::deserialize{Person}(&&json::tdeserializer(json));
if (catch p) {
io::printn("Error deserializing");
return -1;
}
io::printfn("Person named %s with a %s named %s", p.name, p.pets[0].specie, p.pets[0].name);
return 0;
}Required methods must be implemented. Optional methods (@optional) fall back to a required counterpart if not provided.
interface Serializer {
fn void? serialize_bool(bool b);
fn void? serialize_char(char c) @optional; // falls back to serialize_string({c})
fn void? serialize_ichar(ichar c) @optional; // falls back to serialize_long
fn void? serialize_short(short s) @optional; // falls back to serialize_long
fn void? serialize_int(int i) @optional; // falls back to serialize_long
fn void? serialize_long(long l);
fn void? serialize_int128(int128 i) @optional; // returns UNSUPPORTED_DATA_TYPE if absent
fn void? serialize_ushort(ushort s) @optional; // falls back to serialize_ulong
fn void? serialize_uint(uint i) @optional; // falls back to serialize_ulong
fn void? serialize_ulong(ulong l);
fn void? serialize_uint128(uint128 i) @optional; // returns UNSUPPORTED_DATA_TYPE if absent
fn void? serialize_string(String s);
fn void? serialize_zstring(ZString s) @optional; // falls back to serialize_string
fn void? serialize_float(float f) @optional; // falls back to serialize_double
fn void? serialize_double(double d);
fn void? serialize_null();
fn void? serialize_slice_start(ulong len);
fn void? serialize_slice_end(ulong len);
fn void? serialize_field_start(String name);
fn void? serialize_slice_item_start(usz idx) @optional;
fn void? serialize_slice_item_end(usz idx) @optional;
fn void? serialize_field_end(String name);
fn void? struct_start(String name);
fn void? struct_end(String name);
}Required methods must be implemented. Optional methods (@optional) fall back to a required counterpart if not provided.
interface Deserializer {
fn void? struct_start();
fn bool? has_next_field();
fn String? next_field_name();
fn void? struct_end();
fn void? slice_start();
fn bool? has_next_slice_item();
fn void? slice_end();
fn bool? next_bool();
fn bool? next_null();
fn Object*? next_any() @optional; // deserialize any value as Object*
fn char? next_char() @optional; // falls back to next_string()[0]
fn ichar? next_ichar() @optional; // falls back to next_long
fn short? next_short() @optional; // falls back to next_long
fn int? next_int() @optional; // falls back to next_long
fn long? next_long();
fn int128? next_int128() @optional; // returns UNSUPPORTED_DATA_TYPE if absent
fn ushort? next_ushort() @optional; // falls back to next_ulong
fn uint? next_uint() @optional; // falls back to next_ulong
fn ulong? next_ulong();
fn uint128? next_uint128() @optional; // returns UNSUPPORTED_DATA_TYPE if absent
fn String? next_string();
fn ZString? next_zstring() @optional; // falls back to next_string
fn float? next_float() @optional; // falls back to next_double
fn double? next_double();
}To add support for a new format (e.g., YAML, MessagePack), implement the Serializer and Deserializer interfaces (see the json implementation for a model):
struct MySerializer (Serializer) {
// Your implementation
}
fn void? MySerializer.serialize_int(&self, int i) @dynamic {
// Format-specific implementation
}
struct MyDeserializer (Deserializer) {
// Your implementation
}
fn int? MyDeserializer.next_int(&self) @dynamic {
// Format-specific implementation
}Dessert uses C3's fault system for error handling:
| Fault | Module | Description |
|---|---|---|
VALIDATOR_ERROR |
ser |
Field validation failed during serialization |
UNSUPPORTED_DATA_TYPE |
ser / des |
Type has no supported encoding (e.g. int128) |
DUPLICATED_KEY |
des |
Duplicate key found during deserialization |
INVALID_ENUM_VALUE |
des |
Enum name not found during deserialization |
UNKNOWN_FIELD |
des |
Unknown field encountered when deny_unknown_fields is set |
INLINED_UNION_BEFORE_TAG |
des |
Inlined union appeared before its tag field in the input |
UNMAPPED_UNION_VARIANT |
ser / des |
Tag value matched no union member |
INVALID_CSV_TYPE |
dessert::csv |
Unsupported value type during CSV serialization |
INVALID_JSON_TYPE |
json |
Invalid JSON structure |
INVALID_OBJECT |
json |
Expected JSON object |
INVALID_FIELD |
json |
Invalid field format |
INVALID_ARRAY |
json |
Expected JSON array |
INVALID_STRING |
json |
Invalid string format |
INVALID_ESCAPE |
json |
Invalid escape sequence in string |
INVALID_NUMBER |
json |
Invalid number format |
INVALID_BOOLEAN |
json |
Invalid boolean value |
INVALID_NULL |
json |
Invalid null value |
-
Use
derivefor full round-trip support:$expand(derive(MyStruct::name, dessert))generates bothserializeanddeserializeat once with no boilerplate. -
Use skip for sensitive data: Mark fields that shouldn't be serialized (e.g., passwords) with
.skip = true. -
Tag fields before union fields: When deserializing tagged unions, the tag field must appear before the union field in the JSON input. If using
.inlined = true, the tag must appear first in the wire format to avoidINLINED_UNION_BEFORE_TAG. -
Handle unmapped union variants: By default, a tag value that doesn't match any union member raises
UNMAPPED_UNION_VARIANT. Use.unmapped = { .as = NULL }(orEMPTY_STRUCT/ZERO) on the union field to handle gracefully instead.
- Serialize to JSON
- Serialize to CSV (slices of structs)
- Recursive struct serialization
- Serialize Maybe fields
- Serialize slice fields
- Serialize struct fields
- Serialize enum fields (as name, ordinal, or associated field)
- Skip field
- Skip field if empty (
skip_if_empty) - Conditionally skip field via
skip_serializing_<field>method - Rename field
- Validate field
- Deserialize from JSON
- Deserialize enum fields (as name, ordinal, or associated field)
- Enum fallback variant via
@DEnum({ .fallback = "VARIANT" }) - Full primitive type support (all integer, float, string variants)
- Skip unknown fields during deserialization (default)
- Deny unknown fields via
@DStruct({ .deny_unknown_fields = true }) - Deserialize arbitrary JSON into
Object* - Serialize/deserialize tagged union fields (named, anonymous, inlined)
- Field flattening via
@DField({ .flatten = true }) - Bulk field rename via
@DStruct({ .rename_all = CAMEL_CASE }) - Default values for missing fields
MIT License