JSON #

JSON is the universal language of data exchange — almost every modern API uses it. In Dart, dart:convert provides jsonEncode and jsonDecode as the foundation, but proper usage requires more than just those two functions. This article covers type-safe decoding, the fromJson/toJson pattern for model classes, handling nested objects and nullable fields, and json_serializable for code generation that eliminates serialization boilerplate automatically.

The Basics: jsonEncode and jsonDecode #

import 'dart:convert';

// jsonEncode — Dart object → JSON string
// Supports: String, int, double, bool, null, List, Map<String, dynamic>
final map = {'nama': 'Budi', 'umur': 25, 'aktif': true};
final jsonString = jsonEncode(map);
print(jsonString); // '{"nama":"Budi","umur":25,"aktif":true}'

// With indentation — for debugging/logging
final rapi = JsonEncoder.withIndent('  ').convert(map);
print(rapi);
// {
//   "nama": "Budi",
//   "umur": 25,
//   "aktif": true
// }

// jsonDecode — JSON string → Dart object
// Returns dynamic — must be cast explicitly
final decoded = jsonDecode(jsonString);
print(decoded.runtimeType);  // _Map<String, dynamic>

final nama = decoded['nama'] as String;
final umur = decoded['umur'] as int;

Types Supported by JSON #

JSON         → Dart
null         → null
true/false   → bool
number without decimals → int
number with decimals → double
"string"     → String
[...]        → List<dynamic>
{...}        → Map<String, dynamic>
// ANTI-PATTERN: accessing values without explicit casts
final data = jsonDecode(response.body);
String nama = data['nama'];           // ✗ dynamic can't go straight to String
int umur = data['umur'];              // ✗ same
print(data['daftar'].length);         // ✗ dynamic — can crash at runtime

// CORRECT: safe explicit casts
final data = jsonDecode(response.body) as Map<String, dynamic>;
final nama = data['nama'] as String;
final umur = data['umur'] as int;
final daftar = data['daftar'] as List<dynamic>;

Parsing Error Handling #

jsonDecode throws a FormatException for invalid JSON. Always catch it specifically:

import 'dart:convert';

T? parseJson<T>(String input, T Function(dynamic) parser) {
  try {
    final decoded = jsonDecode(input);
    return parser(decoded);
  } on FormatException catch (e) {
    print('Invalid JSON: ${e.message}');
    print('Offset: ${e.offset}');
    return null;
  } on TypeError catch (e) {
    print('Type mismatch: $e');
    return null;
  }
}

// Usage
final pengguna = parseJson(
  responseBody,
  (json) => Pengguna.fromJson(json as Map<String, dynamic>),
);

Model Classes with fromJson and toJson #

This pattern is the standard in Dart — every data model has a fromJson factory constructor and a toJson method:

import 'dart:convert';

class Pengguna {
  final String id;
  final String nama;
  final String email;
  final int umur;
  final bool aktif;

  const Pengguna({
    required this.id,
    required this.nama,
    required this.email,
    required this.umur,
    required this.aktif,
  });

  factory Pengguna.fromJson(Map<String, dynamic> json) {
    return Pengguna(
      id: json['id'] as String,
      nama: json['nama'] as String,
      email: json['email'] as String,
      umur: json['umur'] as int,
      aktif: json['aktif'] as bool? ?? true, // default if the field is absent
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'nama': nama,
    'email': email,
    'umur': umur,
    'aktif': aktif,
  };

  // Helper: JSON string → Pengguna
  static Pengguna fromJsonString(String source) {
    return Pengguna.fromJson(
      jsonDecode(source) as Map<String, dynamic>,
    );
  }

  // Helper: Pengguna → JSON string
  String toJsonString() => jsonEncode(toJson());

  @override
  String toString() => 'Pengguna($id: $nama)';
}

Nested Objects #

Models with fields that are other objects:

class Alamat {
  final String jalan;
  final String kota;
  final String kodePos;

  const Alamat({required this.jalan, required this.kota, required this.kodePos});

  factory Alamat.fromJson(Map<String, dynamic> json) => Alamat(
    jalan: json['jalan'] as String,
    kota: json['kota'] as String,
    kodePos: json['kode_pos'] as String,
  );

  Map<String, dynamic> toJson() => {
    'jalan': jalan,
    'kota': kota,
    'kode_pos': kodePos,
  };
}

class PenggunaDenganAlamat {
  final String nama;
  final Alamat alamat;           // nested object — not null
  final Alamat? alamatPengiriman; // nested nullable — may be null

  const PenggunaDenganAlamat({
    required this.nama,
    required this.alamat,
    this.alamatPengiriman,
  });

  factory PenggunaDenganAlamat.fromJson(Map<String, dynamic> json) {
    return PenggunaDenganAlamat(
      nama: json['nama'] as String,
      // Required nested object
      alamat: Alamat.fromJson(json['alamat'] as Map<String, dynamic>),
      // Nullable nested object — check null before parsing
      alamatPengiriman: json['alamat_pengiriman'] == null
          ? null
          : Alamat.fromJson(json['alamat_pengiriman'] as Map<String, dynamic>),
    );
  }

  Map<String, dynamic> toJson() => {
    'nama': nama,
    'alamat': alamat.toJson(),
    'alamat_pengiriman': alamatPengiriman?.toJson(),
  };
}

Lists of Objects #

class Produk {
  final String id;
  final String nama;
  final double harga;
  final List<String> tag;

  const Produk({required this.id, required this.nama,
      required this.harga, required this.tag});

  factory Produk.fromJson(Map<String, dynamic> json) {
    return Produk(
      id: json['id'] as String,
      nama: json['nama'] as String,
      harga: (json['harga'] as num).toDouble(), // num handles both int and double
      // List of String from a JSON array
      tag: (json['tag'] as List<dynamic>?)
              ?.map((e) => e as String)
              .toList() ??
          [],
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'nama': nama,
    'harga': harga,
    'tag': tag,
  };
}

// Parse a list of objects from a JSON array
List<Produk> parseProdukList(String jsonString) {
  final jsonList = jsonDecode(jsonString) as List<dynamic>;
  return jsonList
      .map((json) => Produk.fromJson(json as Map<String, dynamic>))
      .toList();
}

// Encode a list of objects to a JSON string
String encodeProdukList(List<Produk> produk) {
  return jsonEncode(produk.map((p) => p.toJson()).toList());
}

Handling Inconsistent Types from APIs #

Real-world APIs are often inconsistent — fields that are sometimes integers, sometimes strings:

factory Produk.fromJson(Map<String, dynamic> json) {
  // Price is sometimes int, sometimes double, sometimes string
  final hargaRaw = json['harga'];
  final double harga;
  if (hargaRaw is num) {
    harga = hargaRaw.toDouble();
  } else if (hargaRaw is String) {
    harga = double.tryParse(hargaRaw) ?? 0.0;
  } else {
    harga = 0.0;
  }

  // ID is sometimes int, sometimes string
  final id = json['id'].toString(); // toString() is safe for both

  // Boolean is sometimes 0/1, sometimes true/false
  final aktifRaw = json['aktif'];
  final aktif = aktifRaw == true || aktifRaw == 1 || aktifRaw == '1';

  return Produk(id: id, harga: harga, aktif: aktif, /* ... */);
}

json_serializable — Code Generation #

For many models, writing fromJson and toJson manually is tedious and prone to typos. json_serializable generates this code automatically:

dart pub add json_annotation
dart pub add dev:json_serializable dev:build_runner
// lib/src/model/produk.dart
import 'package:json_annotation/json_annotation.dart';

// The generated part (file name: produk.g.dart)
part 'produk.g.dart';

@JsonSerializable()  // annotation for code generation
class Produk {
  final String id;
  final String nama;

  @JsonKey(name: 'harga_jual')  // JSON field name differs from the Dart field
  final double harga;

  @JsonKey(defaultValue: true)  // default value if the field is absent in JSON
  final bool aktif;

  @JsonKey(name: 'tanggal_dibuat', fromJson: _dateFromJson, toJson: _dateToJson)
  final DateTime tanggalDibuat;

  @JsonKey(includeIfNull: false)  // don't include in JSON if null
  final String? catatan;

  const Produk({
    required this.id,
    required this.nama,
    required this.harga,
    required this.aktif,
    required this.tanggalDibuat,
    this.catatan,
  });

  // Generated methods — don't write manually
  factory Produk.fromJson(Map<String, dynamic> json) =>
      _$ProdukFromJson(json);

  Map<String, dynamic> toJson() => _$ProdukToJson(this);

  // Custom converter for DateTime
  static DateTime _dateFromJson(String date) => DateTime.parse(date);
  static String _dateToJson(DateTime date) => date.toIso8601String();
}
# Generate *.g.dart files
dart run build_runner build

# Watch mode — auto-regenerate when files change
dart run build_runner watch --delete-conflicting-outputs

The generated produk.g.dart file contains complete, type-safe implementations of _$ProdukFromJson and _$ProdukToJson.

json_serializable Configuration #

# Global configuration in build.yaml
# build.yaml (at the project root)
targets:
  $default:
    builders:
      json_serializable:
        options:
          # Use field names as JSON keys by default
          field_rename: snake_case  # namaField → nama_field
          # Don't include null in JSON output by default
          include_if_null: false
          # Use explicit_to_json for nested objects
          explicit_to_json: true
// With the global snake_case configuration, no @JsonKey needed per field
@JsonSerializable(fieldRename: FieldRename.snake)
class PenggunaModel {
  final String namaLengkap;   // → 'nama_lengkap' in JSON
  final String emailUtama;    // → 'email_utama' in JSON
  final int nomorTelepon;     // → 'nomor_telepon' in JSON

  // ...
  factory PenggunaModel.fromJson(Map<String, dynamic> json) =>
      _$PenggunaModelFromJson(json);
  Map<String, dynamic> toJson() => _$PenggunaModelToJson(this);
}

Streaming Large JSON #

For very large JSON files (hundreds of MB), parsing everything at once will cause OOM. Use streaming:

import 'dart:io';
import 'dart:convert';

// Stream parsing — process one JSON per line (NDJSON/JSON Lines format)
Future<void> prosesJsonLines(String path) async {
  final file = File(path);
  int diproses = 0;

  await file
      .openRead()
      .transform(utf8.decoder)
      .transform(const LineSplitter())
      .forEach((baris) {
        if (baris.trim().isEmpty) return;

        try {
          final json = jsonDecode(baris) as Map<String, dynamic>;
          prosesSatuRecord(json);
          diproses++;
        } on FormatException catch (e) {
          print('Line $diproses is invalid: $e');
        }
      });

  print('Finished processing $diproses records');
}

// JsonDecoder as a stream transformer
Stream<dynamic> jsonStream(Stream<String> input) {
  return input.map((chunk) => jsonDecode(chunk));
}

Encoding with a Custom Encoder #

For types not natively supported by JSON (like DateTime, Enum), create a custom encoder:

import 'dart:convert';

// Custom encoder — handle non-standard types
String encodeWithCustomTypes(dynamic object) {
  return jsonEncode(object, toEncodable: (value) {
    if (value is DateTime) {
      return value.toUtc().toIso8601String();
    }
    if (value is Enum) {
      return value.name;
    }
    if (value is Uri) {
      return value.toString();
    }
    throw UnsupportedError('Unsupported type: ${value.runtimeType}');
  });
}

// Usage
final data = {
  'dibuat': DateTime.now(),         // DateTime → ISO 8601 string
  'status': StatusOrder.aktif,      // Enum → name string
  'url': Uri.parse('https://dart.dev'), // Uri → string
};

print(encodeWithCustomTypes(data));
// '{"dibuat":"2024-11-15T07:30:00.000Z","status":"aktif","url":"https://dart.dev"}'

JSON Anti-Patterns #

Casting Without Validation #

// ANTI-PATTERN: long cast chains without validation
final json = jsonDecode(responseBody);
final nama = json['pengguna']['profil']['nama'];  // ✗ can NullPointerError at any level

// CORRECT: gradual validation with null handling
final json = jsonDecode(responseBody) as Map<String, dynamic>?;
if (json == null) return null;

final pengguna = json['pengguna'] as Map<String, dynamic>?;
if (pengguna == null) return null;

final profil = pengguna['profil'] as Map<String, dynamic>?;
final nama = profil?['nama'] as String?;

Using Map<String, dynamic> as a Permanent Model #

// ANTI-PATTERN: storing and passing Map<String, dynamic> across the whole codebase
Future<Map<String, dynamic>> ambilPengguna(String id) async {
  final response = await http.get(Uri.parse('/api/pengguna/$id'));
  return jsonDecode(response.body) as Map<String, dynamic>; // ✗
}

// Elsewhere:
final data = await ambilPengguna('U001');
print(data['Nama']); // ✗ typo 'Nama' vs 'nama' — no compile-time error!

// CORRECT: parse immediately into a type-safe model
Future<Pengguna> ambilPengguna(String id) async {
  final response = await http.get(Uri.parse('/api/pengguna/$id'));
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return Pengguna.fromJson(json); // parse once, type-safe forever
}

final pengguna = await ambilPengguna('U001');
print(pengguna.nama); // ✓ the compiler will catch typos

Forgetting the num Conversion for Numbers #

// ANTI-PATTERN: assuming the price is always a double
factory Produk.fromJson(Map<String, dynamic> json) {
  return Produk(harga: json['harga'] as double); // ✗ if the API sends 150000 (int), crash!
}

// CORRECT: use num.toDouble() — num is the supertype of int and double
factory Produk.fromJson(Map<String, dynamic> json) {
  return Produk(harga: (json['harga'] as num).toDouble()); // ✓ safe for both
}

Summary #

  • jsonDecode returns dynamic — always cast to the expected type (as Map<String, dynamic>, as List<dynamic>) before accessing fields.
  • The fromJson/toJson pattern is the Dart standard for model serialization — parse immediately after receiving network data, don’t pass Map<String, dynamic> around the whole codebase.
  • (json['harga'] as num).toDouble() for numeric fields — num is the supertype of int and double, avoiding crashes when the API sends an integer for a field expected to be a double.
  • Nullable nested objects — check null before calling fromJson: json['alamat'] == null ? null : Alamat.fromJson(json['alamat']).
  • json_serializable for large projects — annotate with @JsonSerializable() and run dart run build_runner build to generate type-safe fromJson/toJson without manual boilerplate.
  • @JsonKey(name: 'snake_case') to map JSON field names that differ from Dart field names, or use fieldRename: FieldRename.snake globally.
  • Custom encoders with toEncodable for non-standard types like DateTime, Enum, and Uri that JSON doesn’t support natively.
  • Streaming for large JSON — NDJSON (one JSON per line) can be processed with LineSplitter without loading the entire file into memory.
  • FormatException is the specific exception from jsonDecode for invalid JSON — catch it specifically, not a generic catch (e).
  • JsonEncoder.withIndent(' ') for human-readable JSON formatting when debugging — don’t use it in production responses to save bandwidth.

← Previous: Mocking   Next: YAML →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact