typeson — Typed JSON values and pluggable (de)serialization for Dart

Typed wrappers for JSON values, ergonomic builders, and a flexible registry for custom (de)serialization. Works great for transforming JSON with type-safe helpers and encoding/decoding your own domain objects.

  • Typed JSON nodes: JsonString, JsonNumber, JsonBoolean, JsonArray, JsonObject
  • Fluent extensions: .json on primitives, lists, and maps; .build for pretty/compact output; .let for inline transforms
  • Registry-based (de)serialization for custom types with pluggable parsers (envelope, flat type key, or your own)
  • Raw/unsafe mode for lazy parsing and zero-copy wrapping of existing structures

Install

Add to your pubspec.yaml:

dependencies:
    tson:
        git: https://github.com/sunarya-thito/tson.git

Requires Dart SDK >= 3.0.0.

Quick start

Primitives

import 'package:typeson/tson.dart';

void main() {
  final JsonString s = 'hello'.json;
  final JsonNumber n = 42.json;
  final JsonBoolean b = true.json;

  print(s.toJson()); // "hello"
  print(n.toJson()); // 42
  print(b.toJson()); // true

  print(n.asString.value); // 42
  print('123'.json.maybeAsNumber?.value); // 123
  print('true'.json.maybeAsBoolean?.value); // true

  final JsonNumber a = 10.json;
  final JsonNumber c = 5.json;
  print((a + c).value); // 15
  print((a > c).value); // true
}

Arrays and objects

import 'package:typeson/typeson.dart';

void main() {
  // Array preserves nulls
  final JsonArray arr = [1, 'two', true, null].json;
  print(arr.toJson()); // [1,"two",true,null]

  // Object: keys stringified, null values kept in the node, but build() can drop nulls from maps (see below)
  final JsonObject obj = {
    'id': 1001,
    'name': 'Widget',
    'tags': ['a', 'b', 'c'],
    'stock': null,
  }.json;

  // Access
  print(obj['name']!.asString.value); // Widget
  print(obj['tags']!.asArray[0]!.asString.value); // a

  // Mutate
  obj['price'] = 9.99.json;
  print(obj['price']!.asNumber.doubleValue); // 9.99
}

Pretty/compact building and null elimination

The .build extension uses JsonBuilder with:

  • indent: spaces per level, null for compact
  • explicitNulls: when false (default), null map entries are removed; list nulls are preserved
import 'package:typeson/typeson.dart';

void main() {
  final JsonObject obj = {
    'id': 7,
    'name': 'Null Demo',
    'note': null, // removed by default
    'nested': {'keepNull': null, 'value': 1},
  }.json;

  // Default: indent=2, explicitNulls=false
  print(obj.build());
  // {
  //   "id": 7,
  //   "name": "Null Demo",
  //   "nested": {
  //     "value": 1
  //   }
  // }

  // Keep nulls
  print(obj.build(explicitNulls: true));
  // {
  //   "id": 7,
  //   "name": "Null Demo",
  //   "note": null,
  //   "nested": {
  //     "keepNull": null,
  //     "value": 1
  //   }
  // }

  // Compact
  print(obj.build(indent: null));
  // {"id":7,"name":"Null Demo","nested":{"value":1}}
}

Parse JSON string to typed nodes

import 'package:typeson/typeson.dart';

void main() {
  const raw = '{"title":"Example","count":3,"items":[{"id":1},{"id":2}]}'
      ;
  final JsonValue value = JsonValue.parse(raw);

  final JsonObject root = value.asObject;
  final title = root['title']!.asString.value;
  final count = root['count']!.asNumber.intValue;
  final firstId = root['items']!.asArray[0]!.asObject['id']!.asNumber.intValue;

  print('title=$title, count=$count, firstId=$firstId');
  // title=Example, count=3, firstId=1

  print(root.build(indent: null));
  // {"title":"Example","count":3,"items":[{"id":1},{"id":2}]}
}

Raw/unsafe mode (lazy parsing, zero-copy)

Use JsonValue.unsafe(...) or the .rawJson extension to wrap existing structures without eagerly converting elements. Booleans and numbers are parsed only when .value is accessed; lists and maps are preserved and lazily wrapped.

import 'package:typeson/typeson.dart';

void main() {
  final rawBool = JsonValue.unsafe({'ok': 'true'});
  final ok = rawBool.asObject['ok']!.asBoolean; // not parsed yet
  print(ok.value); // true

  final rawNum = JsonValue.unsafe('3.14').asNumber; // not parsed yet
  print(rawNum.value); // 3.14

  final rawList = JsonValue.unsafe([1, '2', true, null]).asArray;
  print(rawList[1]!.asNumber.intValue); // 2

  final rawMap = JsonValue.unsafe({'a': '1', 2: false}).asObject;
  print(rawMap['a']!.asNumber.value); // 1
  print(rawMap['2']!.asBoolean.value); // false

  // Mutations store raw values back
  rawMap['x'] = JsonString('hello');
  print(rawMap.toJson()); // {"a":"1","2":false,"x":"hello"}
}

Registry-based custom (de)serialization

Define entries that tell the registry how to encode/decode your types. By default, the registry uses an envelope format: {"__type": "TypeName", "__data": {...}}.

Key pieces:

  • JsonRegistryEntry
  • JsonRegistry: holds entries and an optional default parser
  • JsonObjectParser: strategy for how objects are represented; built-ins include DefaultJsonObjectParser (envelope) and FlatTypeParser (inline with a $type key)
  • JsonRegistryEntry.exactType and .assignableType helpers for matching
import 'package:typeson/typeson.dart';

class PersonName {
  final String firstName;
  final String lastName;
  PersonName(this.firstName, this.lastName);
}

class Person {
  final PersonName name;
  final int age;
  Person(this.name, this.age);
}

void main() {
  final registry = JsonRegistry(
    entries: [
      JsonRegistryEntry<PersonName>(
        type: 'PersonName',
        serializer: (e) =>
            JsonObject.wrap({'firstName': e.firstName, 'lastName': e.lastName}),
        deserializer: (json) => PersonName(
          json['firstName']!.asString.value,
          json['lastName']!.asString.value,
        ),
      ),
      JsonRegistryEntry<Person>(
        type: 'Person',
        serializer: (e) => JsonObject.wrap({
          'name': JsonRegistry.currentRegistry.serialize(e.name).asObject,
          'age': e.age,
        }),
        deserializer: (json) => Person(
          json['name']!.asType<PersonName>(),
          json['age']!.asNumber.intValue,
        ),
      ),
    ],
  );

  final input = [
    Person(PersonName('Alice', 'Smith'), 30),
    {'Bob': Person(PersonName('Bob', 'Brown'), 25)},
  ];

  final JsonValue encoded = registry.serialize(input);
  print(encoded.build());
  // [
  //   {
  //     "__type": "Person",
  //     "__data": {
  //       "name": {
  //         "__type": "PersonName",
  //         "__data": {"firstName": "Alice", "lastName": "Smith"}
  //       },
  //       "age": 30
  //     }
  //   },
  //   {
  //     "Bob": {
  //       "__type": "Person",
  //       "__data": {
  //         "name": {
  //           "__type": "PersonName",
  //           "__data": {"firstName": "Bob", "lastName": "Brown"}
  //         },
  //         "age": 25
  //       }
  //     }
  //   }
  // ]

  final decoded = registry.deserialize(encoded);
  print(decoded is List); // true
}

Flat type parser ($type discriminator)

Use FlatTypeParser to inline a type discriminator instead of the envelope:

import 'package:typeson/typeson.dart';

class Name { final String v; Name(this.v); }

void main() {
  final reg = JsonRegistry(
    parser: const FlatTypeParser(), // defaults to discriminator key "$type"
    entries: [
      JsonRegistryEntry<Name>(
        type: 'Name',
        serializer: (n) => JsonObject.wrap({'v': n.v}),
        deserializer: (json) => Name(json['v']!.asString.value),
      ),
    ],
  );

  final json = reg.serialize(Name('Zed'));
  print(json.build());
  // {
  //   "$type": "Name",
  //   "v": "Zed"
  // }

  final out = json.asObject.asType<Name>(registry: reg);
  print(out.v); // Zed
}

Matching strategy with predicates

Entries can match by exact runtime type (default) or via a predicate like assignableType:

import 'package:typeson/typeson.dart';

abstract class Animal {}
class Dog implements Animal { final String name; Dog(this.name); }

void main() {
  final reg = JsonRegistry(
    entries: [
      JsonRegistryEntry<Animal>(
        type: 'Animal',
        serializer: (a) => JsonObject.wrap({'name': (a as Dog).name}),
        deserializer: (json) => Dog(json['name']!.asString.value),
        check: JsonRegistryEntry.assignableType<Animal>,
      ),
    ],
  );

  final roundTrip = reg.deserialize(reg.serialize(Dog('Rex')));
  print((roundTrip as Dog).name); // Rex
}

API highlights

  • JsonValue
    • factory JsonValue(Object): wrap String/num/bool/List/Map or custom objects via active registry
    • factory JsonValue.parse(String): decode from JSON text
    • factory JsonValue.unsafe(Object): raw wrapper with lazy parsing and zero-copy views
    • toJson(), toEncodeable(), asString/asNumber/asBoolean/asArray/asObject, asType
  • JsonString
    • value, split, asBoolean ("true"/"false"), asNumber, maybeAsBoolean, maybeAsNumber
  • JsonNumber
    • value, intValue, doubleValue, arithmetic and comparisons, asString
  • JsonBoolean
    • value, &, |, ^, ~, asString
  • JsonArray
    • value: List<JsonValue?>, iterable, index access/mutation, add/insert/remove variants, containsElement, asMap(), toEncodeable()
  • JsonObject
    • value: Map<String, JsonValue?>, iterable, key access/mutation, put/update/remove variants, containsKey/containsValue, toEncodeable(), asType
  • Extensions
    • on String/num/bool/List/Map: .json → corresponding JsonValue wrappers
    • on JsonValue: .build({int? indent = 2, bool explicitNulls = false}), .let(...)
    • on Object: .rawJson → JsonValue.unsafe(this)
  • JsonBuilder
    • indent, explicitNulls; eliminate nulls in maps by default; preserves nulls in lists
  • JsonRegistry / JsonRegistryEntry / JsonObjectParser
    • Envelope ("__type"/"__data") by default; FlatTypeParser with "$type"; custom parsers supported

Notes

  • raw.dart is internal; use JsonValue.unsafe(...) or .rawJson instead of importing src directly.
  • .build’s null-elimination only removes nulls from maps; it preserves nulls in lists.
  • When writing serializers for nested custom types, use JsonRegistry.currentRegistry to ensure nested entries get serialized with the active registry.

Run examples/tests (optional)

You can copy any snippet above into a small Dart app. To run the repo tests locally:

dart pub get
dart test

That’s it—enjoy typed JSON with flexible (de)serialization.

Libraries

typeson
A lightweight JSON object model with registry-based (de)serialization.