Skip to main content

Creating JSON

It's also easy to create a typed JSON structure using Ribs using the Json class:

JSON Object
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.

info

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:

Serialzing Json to String
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:

All Json constructors
// 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, returning Either<DecodingFailure, A>.
  • decode(decoder) — apply a decoder to the currently focused node.
Accessing Json values
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:

Exhaustive fold over Json
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 new Json. 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 new Json, letting you swap the node for anything (including Json.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 and withX
// 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 — build, query, and transform
// 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.

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:

Cursor navigation
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 is Json.Null.
  • deepDropNullValues — does the same recursively through nested objects.
deepMerge and dropNullValues
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}}