DEV Community

Capoerus
Capoerus

Posted on

Beyond `json.encode()`: Building a Type-Aware JSON Conversion Layer in Dart

Logo

Reliable data serialization is one of those architectural concerns that often looks simple until an application grows.

At first, converting a Dart object into JSON and decoding it back seems straightforward. Dart already provides a native JSON implementation, and for many applications, it is more than enough.

The challenge begins when an application needs consistent type handling, custom object transformations, database-specific data formats, and predictable behavior across multiple platforms without introducing additional dependencies.

This is one of the problems I addressed while developing convert_json, an internal Dart package built for the Capoerus ecosystem.

Repository: https://github.com/Suebersson/convert_json

The architectural problem: JSON is not the same as a Dart object

JSON supports a limited set of data types: strings, numbers, booleans, null values, arrays, and objects.

Dart applications work with a much broader set of types, including DateTime, RegExp, enums, and custom classes.

The distinction becomes important when data crosses architectural boundaries.

Consider this JSON:

{
  "active": "true",
  "quantity": "100",
  "price": "19.95",
  "createdAt": "2026-10-12T15:30:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

A standard JSON decoder preserves the quoted values as strings. It cannot automatically know that "true" represents a boolean, "100" represents an integer, or the date string should become a DateTime.

This is not necessarily a problem with Dart's JSON implementation. It is a consequence of JSON not preserving the original application's type information.

However, when different parts of an ecosystem repeatedly implement their own conversion rules, maintaining consistent behavior becomes more difficult.

I wanted a small, dependency-free conversion layer that could centralize these decisions while remaining flexible enough to accommodate application-specific requirements.

Why I built convert_json

The package has one responsibility: encode Dart data into JSON and decode JSON into Dart data, with configurable object transformations.

It is written entirely in Dart and does not depend on third-party packages.

The package is maintained in a private-by-design development workflow, with its source available in my GitHub repository. I do not intend to publish it on pub.dev.

This is a deliberate architectural decision. I want the implementation to remain directly available to me and to Capoerus, allowing rapid changes, adjustments, and updates as the ecosystem evolves.

The goal is not to create another general-purpose serialization framework. It is to maintain a focused, reusable component whose behavior I can control.

Recognizing primitive types during decoding

One of the package's features is its ability to recognize certain primitive types represented as strings.

The decoder uses regular expressions to identify potential integers, doubles, booleans, and date-time values before attempting their conversion.

For example, the integer pattern is:

static final RegExp intPattern = RegExp(r'^(-|)[0-9]{1,}$');
Enter fullscreen mode Exit fullscreen mode

The corresponding boolean pattern is:

static final RegExp boolPattern = RegExp(
  r'^(true|false)$',
  caseSensitive: false,
);
Enter fullscreen mode Exit fullscreen mode

The decoder then applies the appropriate conversion:

if (isString && datePattern.hasMatch(value)) {
  return DateTime.tryParse(value) ?? value;
} else if (isString && boolPattern.hasMatch(value)) {
  return bool.tryParse(value, caseSensitive: false) ?? value;
} else if (isString && intPattern.hasMatch(value)) {
  return int.tryParse(value) ?? value;
} else if (isString && doublePattern.hasMatch(value)) {
  return double.tryParse(value) ?? value;
} else {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

This allows matching values to be converted into their corresponding Dart types.

For example:

Input string Resulting Dart type
"true" bool
"100" int
"19.95" double
"2026-10-12T15:30:00.000Z" DateTime, if accepted by the date pattern and parser
"hello" String

The distinction is useful when working with data that has passed through a system that represents values differently from the original application.

There is an important caveat: type inference based on string content is a policy, not a universal truth. A string containing "100" might be an identifier rather than an integer. Applications must decide where this conversion is appropriate and where strings must remain unchanged.

That is why the package also supports explicit transformations.

ObjectTreatment: a controlled extension point

The ObjectTreatment abstraction allows an application to define how particular objects should be processed.

Instead of embedding every possible conversion rule in the package itself, the application provides a list of conditions and transformations.

Conceptually, each treatment contains two callbacks:

  • test: determines whether the object matches a particular rule.
  • treat: transforms the matching object into a JSON-compatible representation or another desired Dart value.

The decoding implementation checks these treatments before attempting primitive conversions:

if (treatments is List<ObjectTreatment> &&
    treatments.isNotEmpty) {
  for (final ObjectTreatment treatment in treatments) {
    if (treatment.test.call(value)) {
      return treatment.treat.call(value);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The first matching treatment takes precedence.

This design separates the conversion mechanism from the application's data policies. The package does not need to understand every database format, domain model, or external API. The application supplies the rules that matter to its own architecture.

The same concept is available during encoding, allowing custom objects to be transformed before the native JSON encoder processes them.

A production example: adapting Dart objects to MongoDB Extended JSON

In Capoerus, one practical use case involves adapting Dart objects to representations supported by MongoDB's Extended JSON conventions.

Two examples are DateTime and RegExp.

1. Encoding DateTime

A Dart DateTime object needs an explicit representation if the receiving system is expected to interpret it as a database date rather than an ordinary string.

The application defines a treatment that converts the date to UTC and produces a MongoDB-style date document:

ObjectTreatment(
  test: (object) => object is DateTime,
  treat: (object) {
    object as DateTime;

    object = object.toUtc();

    String iso8601 = object.toIso8601String();

    final int millisecond = object.millisecond;
    String millisecondTxt = millisecond.toString();

    if (millisecondTxt.length > 3) {
      millisecondTxt = millisecondTxt.substring(0, 3);
    }

    iso8601 = iso8601.replaceFirst(
      millisecondPattern,
      '.${millisecondTxt}Z',
    );

    return {'\$date': iso8601};
  },
)
Enter fullscreen mode Exit fullscreen mode

The resulting representation follows the general shape:

{
  "$date": "2026-10-12T15:30:00.123Z"
}
Enter fullscreen mode Exit fullscreen mode

The treatment makes the timezone and millisecond formatting explicit instead of leaving those decisions scattered across the application.

MongoDB supports several Extended JSON representations, so the exact format should match the interface being used. This example is intended for a compatible date representation, not as a claim that every MongoDB endpoint accepts every Extended JSON variant.

2. Encoding RegExp

Regular expressions introduce a different challenge: the pattern itself is not enough. Relevant flags may also need to be preserved.

The Capoerus implementation transforms a Dart regular expression into a document containing $regex and, when applicable, $options:

ObjectTreatment(
  test: (object) => object is RegExp,
  treat: (object) {
    object as RegExp;

    final Map<String, String> regex = {
      '\$regex': object.pattern,
    };

    if (object.isDotAll) {
      regex.update(
        '\$options',
        (options) => options += 's',
        ifAbsent: () => 's',
      );
    }

    if (!object.isCaseSensitive) {
      regex.update(
        '\$options',
        (options) => options += 'i',
        ifAbsent: () => 'i',
      );
    }

    if (object.isMultiLine) {
      regex.update(
        '\$options',
        (options) => options += 'm',
        ifAbsent: () => 'm',
      );
    }

    if (object.isUnicode) {
      regex.update(
        '\$options',
        (options) => options += 'u',
        ifAbsent: () => 'u',
      );
    }

    return regex;
  },
)
Enter fullscreen mode Exit fullscreen mode

This allows the application to preserve selected regular-expression properties when converting the object into a database-compatible representation.

It also illustrates why a generic JSON encoder alone is not always sufficient for application architecture: the transformation may depend on the expectations of the receiving system.

Keeping the database adaptation outside the core package

The conversion package is intentionally independent of MongoDB.

The database-specific logic belongs to the Capoerus application layer, where the required format and integration behavior are known.

The application's Json class provides a convenient interface around the package:

import 'dart:typed_data' show Uint8List;
import 'package:convert_json/convert_json.dart';

final class Json {
  const Json(this.encoded);

  factory Json.emptyMap() => const Json('{}');

  factory Json.emptyList() => const Json('[]');

  factory Json.fromList(List<dynamic> data) {
    return Json(ConvertJson.encode(data));
  }

  factory Json.fromMap(Map<String, dynamic> data) {
    return Json(ConvertJson.encode(data));
  }

  final String encoded;

  dynamic get decoded => ConvertJson.decode(encoded);

  static String encode(Object codable) {
    return ConvertJson.encode(
      codable,
      treatments: treatmentsForEncoding,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The complete production implementation also includes database-specific formatting and decoding helpers, UTF-8 byte conversion, and immutable treatment configuration.

This separation has an architectural advantage: the package remains focused on conversion, while the application controls how data is prepared for a particular backend.

It also makes it possible to change database formatting rules without turning the general-purpose conversion layer into a database-specific library.

Supporting the reverse transformation

Serialization is only half of the problem. An application must also decide how to interpret the data it receives.

The Capoerus implementation includes a preprocessing function that recognizes selected MongoDB-style representations before passing the resulting JSON to the decoder.

For example, a date expressed in milliseconds since the Unix epoch can be converted into an ISO 8601 string:

encoded = encoded.replaceAllMapped(
  dateMillisecondsSinceEpochPattern,
  (match) {
    final String data = match.group(0) ?? '';
    final String dateTxt =
        digitsOnlyPattern.stringMatch(data) ?? '';

    final int dateEpoch = int.tryParse(dateTxt) ?? 0;

    final DateTime date =
        DateTime.fromMillisecondsSinceEpoch(dateEpoch).toUtc();

    return '"${date.toIso8601String()}"';
  },
);
Enter fullscreen mode Exit fullscreen mode

Once the preprocessing is complete, the decoder can recognize the resulting date string and attempt to create a Dart DateTime.

This is an example of a two-stage conversion pipeline:

  1. Normalize the external representation into ordinary JSON-compatible values.
  2. Decode those values into Dart objects using the configured conversion rules.

The same general approach can be used for other database-specific structures, provided that the transformation is appropriate for the data format being received.

Encoding custom application objects

The package also supports transformations during encoding, including objects that implement a ToJson interface.

A simplified view of the encoder's fallback behavior looks like this:

if (object is Enum) {
  return object.toString();
} else if (object is DateTime) {
  return object.toIso8601String();
} else if (object is ToJson) {
  return object.codable;
} else {
  throw ConvertJsonException(
    'Unsupported object type: ${object.runtimeType}',
  );
}
Enter fullscreen mode Exit fullscreen mode

The actual implementation also checks user-defined ObjectTreatment rules before these fallback cases.

This gives application developers several options:

  • Use the native JSON-compatible types.
  • Define custom treatments for specific objects.
  • Represent domain objects through ToJson.
  • Reject unsupported object types rather than silently assuming they can be serialized.

The last point is especially important. Explicit failure can make architectural problems easier to identify than implicit conversions that produce unexpected representations.

Of course, throwing an exception does not eliminate the need for error handling. Callers must still decide how conversion failures should be reported, logged, or handled.

Why keep the package private instead of publishing it on pub.dev?

Not every useful software component needs to become a public package.

For Capoerus, convert_json is an internal technology intended to support the ecosystem's implementation requirements.

Keeping it unpublished on pub.dev allows me to prioritize the needs of the application rather than maintaining a public release cycle or promising compatibility guarantees to external consumers.

The GitHub repository provides a place to maintain the source and track changes:

https://github.com/Suebersson/convert_json

This approach is particularly useful when the same conversion rules need to evolve alongside the rest of the application.

It does not mean that private packages are inherently more secure or reliable than public packages. Those properties depend on implementation quality, testing, maintenance, and the way the software is used.

The benefit here is control over the component's scope and evolution.

Pure Dart, without third-party dependencies

convert_json uses Dart's native JSON facilities and adds its own conversion logic on top.

It is intended for use across the following platforms:

  • Android
  • iOS
  • Linux
  • macOS
  • Web
  • Windows

Because the package does not depend on third-party packages, it avoids introducing additional package-level dependencies into the projects that consume it.

That does not automatically make the implementation secure, faster, or more reliable. Those qualities still need to be established through testing and appropriate error handling.

What it does provide is a smaller dependency surface and a conversion layer whose behavior is under direct control.

The package's scope is also deliberately limited. It is not intended to replace Dart's JSON implementation, provide a complete object-relational mapping system, or guarantee compatibility with every external data format.

It provides a configurable layer for encoding and decoding data, with additional transformations where the application needs them.

What I consider important in a conversion layer

When working on an application that spans mobile clients, web interfaces, and server-side components, I find it useful to make data-conversion rules explicit and reusable.

A conversion layer should have a clearly defined responsibility, predictable failure behavior, and an extension mechanism for application-specific types.

It should also distinguish between representations that are syntactically valid JSON and representations that carry the intended meaning for a particular application.

For example, the string "100" is valid JSON, but that does not tell us whether the application intended a string identifier or an integer. Likewise, a date string can be valid JSON without necessarily matching the representation expected by a database.

These decisions belong to the architecture, not just to the encoder.

In Capoerus, convert_json is one small component of a broader technology ecosystem. Its value comes from being focused, reusable, and adaptable to the requirements of the systems that depend on it.

Final thoughts

Building reliable software is not always about adopting a large framework or adding another dependency. Sometimes it is about identifying a recurring architectural concern and implementing a focused solution that can be maintained alongside the application.

convert_json is my approach to that problem in Dart: a pure-Dart package, available through GitHub, designed for controlled JSON encoding and decoding across the Capoerus ecosystem.

It is not a universal answer to serialization, and it does not remove the need for validation or error handling. Instead, it provides a place to centralize conversion rules, support custom object transformations, and adapt data to the formats required by the application.

For me, that is the main architectural benefit: having control over how data moves between Dart objects, JSON representations, and the systems that consume them.

Repository: https://github.com/Suebersson/convert_json

How do you handle custom types and database-specific serialization in your Dart applications? Do you prefer a general-purpose serialization framework, native JSON with custom adapters, or an internal conversion layer?

Contato • Website • Links • Play Store

Top comments (0)