Creating JSON
It's also easy to create a typed JSON structure using Ribs using the Json
class:
final anObject = Json.obj([
('key1', Json.True),
('key2', Json.str('some string...')),
(
'key3',
Json.arr([
Json.number(123),
Json.number(3.14),
]),
),
]);
By passing a list of (String, Json) elements to the Json.obj function, we now have a fully
typed Json object that we can interact with using the Ribs json API.
In many cases, you probably won't need to use the Json API itself. It's far
more common to define your domain models, and create encoders and decoders
for those. But it's still worthwhile knowing the Json type is what makes the
higher level APIs work.
What about serializing the JSON? That's also an easy task:
final jsonString = anObject.printWith(Printer.noSpaces);
// {"key1":true,"key2":"some string...","key3":[123,3.14]}
final prettyJsonString = anObject.printWith(Printer.spaces2);
// {
// "key1" : true,
// "key2" : "some string...",
// "key3" : [
// 123,
// 3.14
// ]
// }
Value constructors
Every JSON primitive has a corresponding constructor on Json. The full set is:
// The complete set of Json value constructors
final nullValue = Json.Null; // null
final trueValue = Json.True; // true
final falseValue = Json.False; // false
final boolValue = Json.boolean(true); // same as Json.True, but from a Dart bool
final strValue = Json.str('hello'); // "hello"
final numValue = Json.number(42); // 42
Json.boolean(b) is the dynamic form of the two singleton constants Json.True
and Json.False — use whichever reads more naturally for the situation.
Accessing values
Once you have a Json value you'll often need to pull individual fields out of
it. The cursor API provides two convenient entry points on ACursor:
get(key, decoder)— navigate to a field by name and decode it in one step, returningEither<DecodingFailure, A>.decode(decoder)— apply a decoder to the currently focused node.
final json = Json.obj([
('name', Json.str('Ribs')),
('version', Json.number(1)),
('stable', Json.False),
('tags', Json.arr([Json.str('dart'), Json.str('fp')])),
]);
final name = json.hcursor.get('name', Decoder.string);
// Right('Ribs')
final missing = json.hcursor.get('missing', Decoder.string);
// Left(DecodingFailure: ...)
// For quick type checks without decoding, use the boolean predicates
final strNode = Json.str('hello');
print(strNode.isString); // true
print(strNode.isNumber); // false
// asX() gives direct access without a cursor — returns Option<T>
print(Json.number(3.14).asNumber()); // Some(3.14)
print(Json.str('hi').asString()); // Some(hi)
print(Json.True.asBoolean()); // Some(true)
print(Json.Null.asNull()); // Some(Unit)
print(Json.str('hi').asNumber()); // None
asString(), asNumber(), asBoolean(), asArray(), and asObject() are
also available directly on Json and return Option<T> — useful when you just
want to extract a value without going through a full decoder.
Pattern matching with fold
Json.fold is the exhaustive pattern match over all six JSON types. Every
branch must be handled, so the compiler guarantees you never miss a case:
String describe(Json json) => json.fold(
() => 'null',
(b) => 'boolean: $b',
(n) => 'number: $n',
(s) => 'string: "$s"',
(arr) => 'array of ${arr.length} elements',
(obj) => 'object with keys: ${obj.keys.toList()}',
);
print(describe(Json.str('hello'))); // string: "hello"
print(describe(Json.number(42))); // number: 42
print(describe(Json.arr([Json.Null]))); // array of 1 elements
print(describe(Json.obj([('a', Json.True)]))); // object with keys: [a]
Targeted transformations — mapX and withX
Sometimes you need to modify a specific kind of Json node without writing a
full fold. Two families of methods cover this:
mapX(f)— transforms the typed value in-place and returns a newJson. If the node is a different type the call is a no-op and the original is returned unchanged.withX(f)— receives the typed value and returns a completely newJson, letting you swap the node for anything (includingJson.Null). Also a no-op on a wrong-typed node.
Both families exist for all six JSON types: mapBoolean, mapNumber,
mapString, mapArray, mapObject, and the corresponding withBoolean,
withNumber, withString, withArray, withObject, withNull.
// mapX transforms a value in-place; it's a no-op when the node is a different type
final doubled = Json.number(21).mapNumber((n) => n * 2); // 42
final upper = Json.str('hello').mapString((s) => s.toUpperCase()); // "HELLO"
final toggled = Json.True.mapBoolean((b) => !b); // false
final noop = Json.str('hello').mapNumber((n) => n * 2); // still "hello"
// withX replaces the whole node using the typed value — useful for conditional swaps
final replaced = Json.number(0).withNumber(
(n) => n == 0 ? Json.Null : Json.number(n),
); // Json.Null
// A practical use: redact every string field in an object
final sensitive = Json.obj([
('user', Json.str('alice')),
('token', Json.str('secret-token-123')),
('score', Json.number(42)),
]);
final redacted = sensitive.mapObject(
(obj) => obj.mapValues((v) => v.mapString((_) => '***')),
);
print(redacted.printWith(Printer.noSpaces));
// {"user":"***","token":"***","score":42}
Working with JsonObject directly
JsonObject is the immutable map type that backs every JSON object node. You
get one from asObject() or in the jsonObject branch of fold, and you can
also build one directly when you need fine-grained control:
// JsonObject is the type that backs JObject — you can build and query it directly
var obj = JsonObject.empty;
obj = obj.add('name', Json.str('Ribs'));
obj = obj.add('version', Json.number(1));
obj = obj.add('stable', Json.False);
print(obj.size); // 3
print(obj.contains('name')); // true
// get() returns Option<Json>
print(obj.get('name')); // Some("Ribs")
print(obj.get('missing')); // None
// remove() returns a new JsonObject without the key
final trimmed = obj.remove('stable');
// toJson() wraps the object back into a Json value
print(trimmed.toJson().printWith(Printer.noSpaces));
// {"name":"Ribs","version":1}
// filter() keeps only entries matching a predicate
final numbersOnly = obj.filter((kv) => kv.$2.isNumber);
print(numbersOnly.toJson().printWith(Printer.noSpaces));
// {"version":1}
// mapValues() transforms every value in the object
final stringified = obj.mapValues(
(v) => v.mapNumber((n) => n + 10),
);
print(stringified.toJson().printWith(Printer.noSpaces));
// {"name":"Ribs","version":11,"stable":false}
JsonObject is immutable: add, remove, and mapValues all return new
instances. Call .toJson() to wrap the result back into a Json value for
printing or encoding.
Navigating nested structures with the cursor
For nested JSON the cursor supports chaining navigation steps. downField(key)
moves into an object field and downN(n) moves to an array element at index
n. Each step returns an ACursor that can be chained further or decoded
directly with get / decode:
final json = Json.obj([
('project', Json.str('Ribs')),
(
'contributors',
Json.arr([
Json.obj([('name', Json.str('Alice')), ('commits', Json.number(42))]),
Json.obj([('name', Json.str('Bob')), ('commits', Json.number(17))]),
]),
),
]);
final cursor = json.hcursor;
// Navigate into an array element with downField + downN
final firstCommits = cursor.downField('contributors').downN(0).get('commits', Decoder.integer);
// Right(42)
// getOrElse provides a fallback for missing or null fields
final version = cursor.getOrElse('version', Decoder.integer, () => 0);
// Right(0) — 'version' is absent, so the fallback fires
// pathString gives a human-readable description of the cursor position
final c = cursor.downField('contributors').downN(0).downField('name');
print(c.pathString); // .contributors[0].name
getOrElse is useful for optional fields: if the key is absent or the decode
fails it calls the fallback Function0 instead. pathString gives a
human-readable dot/bracket path to the current focus, which is handy for
error messages.
Merging and cleaning up objects
Two utility methods make it easy to compose or sanitise JSON objects:
deepMerge— recursively merges two objects; keys in the right operand win, and nested objects are merged rather than replaced.dropNullValues— removes top-level fields whose value isJson.Null.deepDropNullValues— does the same recursively through nested objects.
final defaults = Json.obj([
('timeout', Json.number(30)),
('retries', Json.number(3)),
('debug', Json.Null),
]);
final overrides = Json.obj([
('retries', Json.number(5)),
('host', Json.str('localhost')),
]);
// deepMerge: keys in the right operand win; nested objects are merged recursively
final merged = defaults.deepMerge(overrides);
print(merged.printWith(Printer.noSpaces));
// {"timeout":30,"retries":5,"debug":null,"host":"localhost"}
// dropNullValues removes top-level null fields from an object
final clean = merged.dropNullValues();
print(clean.printWith(Printer.noSpaces));
// {"timeout":30,"retries":5,"host":"localhost"}
// deepDropNullValues removes nulls recursively through nested objects
final nested = Json.obj([
('a', Json.Null),
('b', Json.obj([('c', Json.Null), ('d', Json.number(1))])),
]);
print(nested.deepDropNullValues().printWith(Printer.noSpaces));
// {"b":{"d":1}}