skip to content

In Dart, how does jsonEncode handle a custom settings object, and how do toJson(), toEncodable and JsonEncoder.withIndent produce pretty-printed JSON?

level: middleimportance: must knowfreq 52%

answer

  1. encodable: num, String, bool, null, List, Map
  2. default toEncodable calls toJson()
  3. JsonUnsupportedObjectError when it fails
  4. withIndent(indent, [toEncodable])
  5. DateTime, enum and Set need converting

basics

~10 s

jsonEncode writes numbers, strings, booleans, null, lists and string-keyed maps directly; for anything else it calls toEncodable, which by default calls the object's toJson(). For pretty output use JsonEncoder.withIndent(' ').convert(settings).

solid answer

~40 s

The encoder handles `num`, `String`, `bool`, `null`, `List` and `Map` with `String` keys directly. Any other value is passed to `toEncodable`, whose default calls the object's `toJson()` method dynamically; whatever comes back must itself be encodable, and it is encoded recursively. If `toJson` is missing, throws, or returns something unencodable, encoding fails with a `JsonUnsupportedObjectError`, with the original exception in `cause`, and a list or map that contains itself raises `JsonCyclicError`. So a settings class writes `toJson()` returning a `Map<String, Object?>`, converting what JSON lacks: an enum to `theme.name`, a `DateTime` to `toIso8601String()`, a `Set` to a list. `jsonEncode` has no indent option; for a readable settings file use `const JsonEncoder.withIndent(' ')`, which also accepts an optional `toEncodable` as its second argument.

code

dart · 36 lines
dart
import 'dart:convert';

enum ThemeChoice { system, light, dark }

class AppSettings {
  AppSettings({
    required this.theme,
    required this.fontScale,
    required this.lastSync,
    required this.pinned,
  });

  final ThemeChoice theme;
  final double fontScale;
  final DateTime lastSync;
  final Set<String> pinned;

  Map<String, Object?> toJson() => {
    'theme': theme.name,
    'fontScale': fontScale,
    'lastSync': lastSync.toUtc().toIso8601String(),
    'pinned': pinned.toList(),
  };
}

void main() {
  final settings = AppSettings(
    theme: ThemeChoice.dark,
    fontScale: 1.2,
    lastSync: DateTime.utc(2026, 9, 29, 8),
    pinned: {'java', 'dart'},
  );
  const pretty = JsonEncoder.withIndent('  ');
  print(pretty.convert(settings)); // toJson() is called for you
  // jsonEncode({'pinned': {'java'}}); // JsonUnsupportedObjectError: a Set
}

go deeper

for a junior

Know which types encode directly and that jsonEncode calls toJson() on your objects; know JsonEncoder.withIndent for readable output.

for a middle

Explain the toEncodable fallback, the recursive handling of toJson() results, and why enums, DateTime, Set and int-keyed maps need converting.

for a senior

Keep a single toJson() per type, use toEncodable for types you do not own, and treat JsonUnsupportedObjectError as a bug to fix, not to catch.

for a principal

Choose between hand-written and generated serialization for the codebase, weighing review cost, build steps and how format changes are versioned.

## What the encoder writes directly `jsonEncode(object)` is shorthand for `json.encode`. The `JsonEncoder` documentation lists the **directly serializable** values: - `num`, `String`, `bool` and `null` - `List` whose elements are serializable - `Map` whose keys are all `String` and whose values are serializable Everything else, including your own classes, `DateTime`, `Duration`, enums, `Set`s, `Uri`s and maps with `int` keys, goes through the **`toEncodable`** fallback. Non-finite doubles such as `double.nan` and `double.infinity` are not directly serializable either. ## The `toJson()` hook If you do not pass `toEncodable`, it defaults to a function that calls **`object.toJson()`**. The call is dynamic, so there is no interface to implement; any object with a `toJson()` method works. The encoder then: 1. calls `toJson()` on the unencodable object; 2. checks the result is directly serializable, a map or list is fine; 3. encodes that result recursively, so nested objects with their own `toJson()` are handled too. If `toJson` does not exist, throws, or returns something unencodable, `jsonEncode` throws a **`JsonUnsupportedObjectError`**. Its `unsupportedObject` field names the culprit, `cause` holds the exception if the conversion threw, and `partialResult` may hold the output so far. A list or map that contains itself raises the subclass **`JsonCyclicError`**. Both are `Error`s, meaning a programming bug, not an input problem. ## Writing `toJson()` for a settings object A settings object usually mixes types JSON does not have. Convert each field on the way out: | Field type | Write it as | |---|---| | enum | `theme.name`, a `String` | | `DateTime` | `lastSync.toUtc().toIso8601String()` | | `Duration` | `timeout.inMilliseconds`, an `int` | | `Set<String>` | `pinned.toList()` | | nested settings object | leave it; its own `toJson()` is called | Return `Map<String, Object?>` or `Map<String, dynamic>`. Keep the conversion in `toJson()` rather than in callers, so every writer produces the same format. Code generators can write these methods for model classes, but the hook they target is this same `toJson()`. ## `toEncodable` for types you do not own When you cannot add `toJson()`, for example to `DateTime`, pass a function: - `jsonEncode(data, toEncodable: (o) => o is DateTime ? o.toIso8601String() : throw UnsupportedError('$o'))` - The function receives each unencodable value and must return an encodable replacement. - Passing `toEncodable` **replaces** the default, so `toJson()` is no longer called unless your function calls it. ## Pretty printing `jsonEncode` always produces compact, single-line output. For a settings file people may read or diff: 1. Create `const JsonEncoder.withIndent(' ')`. The string is inserted once per nesting level at the start of each indented line. 2. Call `encoder.convert(settings)`. The same `toJson()` default applies. 3. To combine indentation with a custom fallback, pass it as the second argument: `JsonEncoder.withIndent(' ', myToEncodable)`. 4. Use only JSON whitespace, spaces or tabs, in the indent string, or the output stops being valid JSON. ## Reading the file back Writing is half the format. The same settings file must decode into the same object: 1. Decode with `jsonDecode`, which returns `dynamic` built from maps and lists. 2. Convert each field back: `ThemeChoice.values.byName(json['theme'] as String)` for the enum, `DateTime.parse` for the timestamp, `.toSet()` on the list for the `Set`. 3. Supply defaults for keys an older version of the file did not have, so adding a setting does not break existing users. 4. Keep `toJson()` and the reading code next to each other, and test that encoding then decoding returns an equal object. A round-trip test catches most format drift, such as renaming a key on the write side only, before it reaches a user's device. ## Mistakes interviewers listen for - Expecting `jsonEncode(settings)` to find fields by reflection. Dart's encoder uses only the `toJson()` hook or `toEncodable`. - Returning a `DateTime` or an enum inside the `toJson()` map and meeting `JsonUnsupportedObjectError`. - Using a `Set` or an `int`-keyed map and assuming it converts automatically. - Looking for an `indent` parameter on `jsonEncode` instead of using `JsonEncoder.withIndent`.

  • What does jsonEncode({1: 'a'}) do?
    It throws a `JsonUnsupportedObjectError`. JSON object keys must be strings, and the encoder writes a map directly only when all keys are `String`; otherwise it falls back to `toEncodable`, and a map has no `toJson()`. Convert keys first, for example with `map.map((k, v) => MapEntry('$k', v))`.
  • If you pass toEncodable to jsonEncode, is toJson() still called?
    Not automatically. `toEncodable` replaces the default function, which is the one that calls `toJson()`. If some objects rely on `toJson()`, your function has to call it for them, for example by checking the types it handles and delegating the rest.
  • Why is JsonUnsupportedObjectError an Error rather than an Exception?
    Because failing to encode means the program handed the encoder data it was never prepared to convert, a bug in the code, not a condition of the input. The fix is a `toJson()` or `toEncodable` change, not a catch block around every encode call.

The encoder is a clerk who files only standard forms. Handed anything else, it asks the object to fill in a standard form itself by calling toJson(); if the object cannot, the clerk refuses the whole filing.

saying these in an interview costs you the question

  • jsonEncode serializes an object's public fields automatically
  • A class must implement a JsonSerializable interface for toJson() to be used
  • DateTime values are written as ISO strings automatically
  • jsonEncode takes an indent parameter for pretty printing
  • A Set is encoded as a JSON array like a List