JSON

JSON #

JSON adalah bahasa universal pertukaran data — hampir semua API modern menggunakannya. Di Dart, dart:convert menyediakan jsonEncode dan jsonDecode sebagai fondasi, tapi penggunaan yang benar membutuhkan lebih dari sekadar dua fungsi itu. Artikel ini membahas dari decoding yang type-safe, pola fromJson/toJson untuk model kelas, penanganan nested object dan nullable field, hingga json_serializable untuk code generation yang menghilangkan boilerplate serialisasi secara otomatis.

Dasar: jsonEncode dan jsonDecode #

import 'dart:convert';

// jsonEncode — Dart object → JSON string
// Mendukung: 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}'

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

// jsonDecode — JSON string → Dart object
// Mengembalikan dynamic — harus di-cast secara eksplisit
final decoded = jsonDecode(jsonString);
print(decoded.runtimeType);  // _Map<String, dynamic>

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

Tipe yang Didukung JSON #

JSON         → Dart
null         → null
true/false   → bool
angka tanpa desimal → int
angka dengan desimal → double
"string"     → String
[...]        → List<dynamic>
{...}        → Map<String, dynamic>
// ANTI-PATTERN: mengakses nilai tanpa cast eksplisit
final data = jsonDecode(response.body);
String nama = data['nama'];           // ✗ dynamic tidak bisa langsung ke String
int umur = data['umur'];              // ✗ sama
print(data['daftar'].length);         // ✗ dynamic — bisa crash saat runtime

// BENAR: cast eksplisit yang aman
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>;

Penanganan Error Parsing #

jsonDecode melempar FormatException untuk JSON yang tidak valid. Selalu tangkap secara spesifik:

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('JSON tidak valid: ${e.message}');
    print('Offset: ${e.offset}');
    return null;
  } on TypeError catch (e) {
    print('Tipe tidak sesuai: $e');
    return null;
  }
}

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

Model Kelas dengan fromJson dan toJson #

Pola ini adalah standar dalam Dart — setiap model data memiliki factory constructor fromJson dan method toJson:

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 jika field tidak ada
    );
  }

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

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

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

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

Nested Object #

Model yang memiliki field berupa objek lain:

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 — bukan null
  final Alamat? alamatPengiriman; // nested nullable — boleh 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,
      // Nested object wajib
      alamat: Alamat.fromJson(json['alamat'] as Map<String, dynamic>),
      // Nested object nullable — cek null sebelum parse
      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(),
  };
}

List 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 menangani int dan double
      // List of String dari 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 list of objects dari 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 list of objects ke JSON string
String encodeProdukList(List<Produk> produk) {
  return jsonEncode(produk.map((p) => p.toJson()).toList());
}

Menangani Tipe yang Tidak Konsisten dari API #

API di dunia nyata sering tidak konsisten — field yang kadang integer, kadang string:

factory Produk.fromJson(Map<String, dynamic> json) {
  // Harga kadang int, kadang double, kadang 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 kadang int, kadang string
  final id = json['id'].toString(); // toString() aman untuk keduanya

  // Boolean kadang 0/1, kadang 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 #

Untuk model yang banyak, menulis fromJson dan toJson secara manual sangat tedious dan rentan typo. json_serializable menghasilkan kode ini secara otomatis:

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';

// Bagian yang di-generate (nama file: produk.g.dart)
part 'produk.g.dart';

@JsonSerializable()  // anotasi untuk code generation
class Produk {
  final String id;
  final String nama;

  @JsonKey(name: 'harga_jual')  // nama field JSON berbeda dari field Dart
  final double harga;

  @JsonKey(defaultValue: true)  // nilai default jika field tidak ada di JSON
  final bool aktif;

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

  @JsonKey(includeIfNull: false)  // jangan include di JSON jika 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 — jangan tulis manual
  factory Produk.fromJson(Map<String, dynamic> json) =>
      _$ProdukFromJson(json);

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

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

# Watch mode — auto-regenerate saat file berubah
dart run build_runner watch --delete-conflicting-outputs

File produk.g.dart yang di-generate berisi implementasi _$ProdukFromJson dan _$ProdukToJson yang lengkap dan type-safe.

Konfigurasi json_serializable #

// Konfigurasi global di build.yaml
// build.yaml (di root project)
targets:
  $default:
    builders:
      json_serializable:
        options:
          # Gunakan field name sebagai JSON key secara default
          field_rename: snake_case  # namaField  nama_field
          # Jangan include null di output JSON secara default
          include_if_null: false
          # Gunakan explicit_to_json untuk nested objects
          explicit_to_json: true
// Dengan konfigurasi snake_case global, tidak perlu @JsonKey untuk setiap field
@JsonSerializable(fieldRename: FieldRename.snake)
class PenggunaModel {
  final String namaLengkap;   // → 'nama_lengkap' di JSON
  final String emailUtama;    // → 'email_utama' di JSON
  final int nomorTelepon;     // → 'nomor_telepon' di JSON

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

Streaming JSON Besar #

Untuk file JSON yang sangat besar (ratusan MB), parsing sekaligus akan menyebabkan OOM. Gunakan streaming:

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

// Stream parsing — proses satu baris JSON per baris (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('Baris $diproses tidak valid: $e');
        }
      });

  print('Selesai memproses $diproses record');
}

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

Encode dengan Custom Encoder #

Untuk tipe yang tidak didukung JSON secara native (seperti DateTime, Enum), buat custom encoder:

import 'dart:convert';

// Custom encoder — tangani tipe non-standar
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('Tipe tidak didukung: ${value.runtimeType}');
  });
}

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

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

Anti-Pattern JSON #

Cast Tanpa Validasi #

// ANTI-PATTERN: chain cast panjang tanpa validasi
final json = jsonDecode(responseBody);
final nama = json['pengguna']['profil']['nama'];  // ✗ bisa NullPointerError di setiap level

// BENAR: validasi bertahap dengan penanganan null
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?;

Menggunakan Map<String, dynamic> sebagai Model Permanen #

// ANTI-PATTERN: simpan dan teruskan Map<String, dynamic> di seluruh 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>; // ✗
}

// Di tempat lain:
final data = await ambilPengguna('U001');
print(data['Nama']); // ✗ typo 'Nama' vs 'nama' — tidak ada error saat compile!

// BENAR: parse segera ke model yang type-safe
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 sekali, type-safe selamanya
}

final pengguna = await ambilPengguna('U001');
print(pengguna.nama); // ✓ compiler akan tangkap typo

Lupa Konversi num untuk Angka #

// ANTI-PATTERN: asumsi harga selalu double
factory Produk.fromJson(Map<String, dynamic> json) {
  return Produk(harga: json['harga'] as double); // ✗ jika API kirim 150000 (int), crash!
}

// BENAR: gunakan num.toDouble() — num adalah supertype dari int dan double
factory Produk.fromJson(Map<String, dynamic> json) {
  return Produk(harga: (json['harga'] as num).toDouble()); // ✓ aman untuk keduanya
}

Ringkasan #

  • jsonDecode mengembalikan dynamic — selalu cast ke tipe yang diharapkan (as Map<String, dynamic>, as List<dynamic>) sebelum mengakses field.
  • Pola fromJson/toJson adalah standar Dart untuk serialisasi model — parse segera setelah terima data jaringan, jangan teruskan Map<String, dynamic> ke seluruh codebase.
  • (json['harga'] as num).toDouble() untuk field angka — num adalah supertype dari int dan double, menghindari crash ketika API mengirim integer untuk field yang diharapkan double.
  • Nested object nullable — cek null dulu sebelum memanggil fromJson: json['alamat'] == null ? null : Alamat.fromJson(json['alamat']).
  • json_serializable untuk proyek besar — anotasi @JsonSerializable() dan dart run build_runner build menghasilkan fromJson/toJson yang type-safe tanpa boilerplate manual.
  • @JsonKey(name: 'snake_case') untuk memetakan nama field JSON yang berbeda dari nama field Dart, atau gunakan fieldRename: FieldRename.snake secara global.
  • Custom encoder dengan toEncodable untuk tipe non-standar seperti DateTime, Enum, dan Uri yang tidak didukung JSON secara native.
  • Streaming untuk JSON besar — NDJSON (satu JSON per baris) bisa diproses dengan LineSplitter tanpa memuat seluruh file ke memori.
  • FormatException adalah exception spesifik dari jsonDecode untuk JSON tidak valid — tangkap secara spesifik, bukan catch (e) generik.
  • JsonEncoder.withIndent(' ') untuk format JSON yang mudah dibaca manusia saat debugging — jangan gunakan di production response untuk menghemat bandwidth.

← Sebelumnya: Mocking   Berikutnya: YAML →

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