JSON & Serialisasi #

Hampir seluruh data yang dipertukarkan antara aplikasi Flutter kita dengan server API dikirimkan dalam format JSON (JavaScript Object Notation). Ketika aplikasi Flutter menerima data JSON dari internet, data tersebut masih berupa string teks mentah yang tidak memiliki tipe data aman (untyped string). Agar kita dapat menggunakan data tersebut di dalam kode antarmuka UI dengan aman tanpa khawatir terjadi error salah eja atau tipe data yang tidak cocok, kita harus melakukan konversi data.

Proses konversi data ini terbagi menjadi dua arah:

  1. Deserialisasi (Parsing JSON): Mengubah string teks JSON dari API menjadi instansi objek kelas Dart yang bertipe data aman (type-safe objects).
  2. Serialisasi: Mengubah kembali instansi objek kelas Dart menjadi struktur Map yang kemudian di-encode menjadi string JSON teks mentah untuk dikirimkan sebagai body request ke server API.

Di ekosistem Flutter, kita dapat memilih berbagai tingkat otomatisasi untuk melakukan pekerjaan ini. Mulai dari menulis fungsi konversi secara manual, memanfaatkan pustaka otomatis berbasis generator kode (code generation), hingga menggunakan model data imutabel modern yang siap pakai untuk skala produksi.

Berikut adalah diagram alir pipa data (data pipeline) yang menggambarkan bagaimana data JSON mentah bertransformasi hingga menjadi objek siap pakai di UI kita:

graph TD
    classDef default stroke:#333,stroke-width:2px;
    
    A["JSON Mentah (String dari API/Internet)"] -->|"jsonDecode()"| B["Map dinamis (Map String, dynamic)"]
    B -->|"factory Model.fromJson()"| C["Instansi Objek Model (Type-Safe Object)"]
    C -->|"State Management / UI"| D["Tampilan Visual Layar (Widget)"]
    
    C -->|"Model.toJson()"| E["Map dinamis (Map String, dynamic) untuk Request"]
    E -->|"jsonEncode()"| F["JSON String Mentah untuk Body Request"]
    F -->|"Kirim ke API / Internet"| G["Server API Backend"]

Pendekatan 1: Parsing Manual Berbasis dart:convert #

Pendekatan paling dasar adalah menulis fungsi konversi secara manual tanpa bantuan pustaka eksternal apa pun. Kita menggunakan metode bawaan SDK Dart jsonDecode() dari package dart:convert untuk mengubah teks string menjadi objek Map<String, dynamic>, lalu memetakan setiap kunci (key) ke properti kelas Dart kita secara manual.

Contoh Penerapan Model Manual #

Mari kita buat kelas model data Produk dengan menulis fungsi deserialisasi fromJson dan serialisasi toJson secara manual:

import 'dart:convert';

class Produk {
  final String id;
  final String nama;
  final double harga;
  final bool tersedia;
  final List<String> kategori;

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

  // 1. DESERIALISASI: Mengubah Map hasil decode menjadi objek Produk
  factory Produk.fromJson(Map<String, dynamic> json) {
    return Produk(
      id: json['id'] as String,
      nama: json['nama'] as String,
      // API terkadang mengirimkan harga sebagai integer atau double, 
      // casting ke 'num' terlebih dahulu lalu ubah ke double sangat direkomendasikan
      harga: (json['harga'] as num).toDouble(),
      tersedia: json['tersedia'] as bool,
      kategori: (json['kategori'] as List<dynamic>)
          .map((item) => item as String)
          .toList(),
    );
  }

  // 2. SERIALISASI: Mengubah properti objek menjadi Map untuk dikirim ke API
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'nama': nama,
      'harga': harga,
      'tersedia': tersedia,
      'kategori': kategori,
    };
  }
}

// === Cara Menggunakannya di Kode Aplikasi Kita ===

// A. Deserialisasi objek tunggal
final String responseBodyTunggal = '{"id":"1","nama":"Kopi Luwak","harga":45000.0,"tersedia":true,"kategori":["minuman"]}';
final Map<String, dynamic> mapTunggal = jsonDecode(responseBodyTunggal);
final Produk produkKopi = Produk.fromJson(mapTunggal);

// B. Deserialisasi daftar objek (List)
final String responseBodyList = '[{"id":"1","nama":"Kopi Luwak","harga":45000.0,"tersedia":true,"kategori":["minuman"]}]';
final List<dynamic> listMentah = jsonDecode(responseBodyList);
final List<Produk> listProduk = listMentah.map((item) => Produk.fromJson(item as Map<String, dynamic>)).toList();

Keterbatasan Fatal Menulis Manual #

Walaupun menulis kode secara manual sangat memuaskan karena kita memegang kendali penuh tanpa dependensi tambahan, pola ini memiliki kelemahan yang sangat fatal saat aplikasi kita mulai membesar:

  • Sangat Repetitif & Membosankan: Kita harus menulis boilerplate kode fromJson dan toJson yang identik untuk puluhan kelas model data yang kita miliki.
  • Rawan Kesalahan Ejaan (Typo): Jika server mengubah nama kunci dari "nama" menjadi "name", kita harus mengubah string tersebut secara manual di kode kita. Tidak ada perlindungan dari compiler (compile-time check) untuk typo dalam string map.
  • Tidak Mendukung Pembandingan Nilai Secara Default: Di Dart, dua instansi objek kelas yang memiliki nilai field yang sama persis akan dianggap berbeda oleh operator == jika alamat memorinya berbeda. Kita harus menulis metode override == dan hashCode secara manual untuk setiap kelas.
  • Boilerplate copyWith: Kita harus menulis sendiri fungsi copyWith jika ingin memodifikasi data imutabel dengan aman.

Pendekatan 2: json_serializable untuk Otomatisasi Terarah #

Untuk membebaskan kita dari penulisan kode konversi manual yang berulang, kita dapat menggunakan pustaka json_serializable. Pustaka ini menggunakan konsep generator kode (code generation): kita cukup menambahkan anotasi sederhana pada kelas model kita, dan generator akan otomatis menulis berkas pembantu .g.dart yang menangani logika konversi JSON di belakang layar.

Konfigurasi Dependensi #

Tambahkan dependensi berikut di berkas pubspec.yaml proyek kita:

dependencies:
  # Menyediakan anotasi @JsonSerializable
  json_annotation: ^4.9.0

dev_dependencies:
  # Menjalankan mesin generator kode di Dart
  build_runner: ^2.4.13
  # Generator kode spesifik untuk json_serializable
  json_serializable: ^6.8.0
// produk.dart
import 'package:json_annotation/json_annotation.dart';

// 1. Deklarasikan file part yang akan otomatis di-generate nanti
part 'produk.g.dart';

// 2. Berikan anotasi @JsonSerializable
@JsonSerializable()
class Produk {
  final String id;
  final String nama;
  final double harga;
  final bool tersedia;
  final List<String> kategori;

  // Mengubah nama kunci JSON yang tidak sesuai dengan konvensi penulisan camelCase Dart
  @JsonKey(name: 'created_at')
  final DateTime tanggalDibuat;

  // Memberikan nilai default jika server tidak mengirimkan field tersebut
  @JsonKey(defaultValue: false)
  final bool isDiskon;

  const Produk({
    required this.id,
    required this.nama,
    required this.harga,
    required this.tersedia,
    required this.kategori,
    required this.tanggalDibuat,
    this.isDiskon = false,
  });

  // 3. Hubungkan constructor factory ke fungsi yang di-generate otomatis
  factory Produk.fromJson(Map<String, dynamic> json) => _$ProdukFromJson(json);
  
  // 4. Hubungkan metode toJson ke fungsi yang di-generate otomatis
  Map<String, dynamic> toJson() => _$ProdukToJson(this);
}

Menjalankan Generator build_runner #

Ketika berkas di atas dibuat, Flutter akan memunculkan pesan error karena berkas produk.g.dart belum ada. Kita harus memicu generator menggunakan terminal:

# Menjalankan generator satu kali untuk membuat berkas *.g.dart
rtk flutter pub run build_runner build --delete-conflicting-outputs

Selama masa pengembangan aktif, jalankan generator dalam mode watch agar ia memantau perubahan berkas secara real-time:

# Generator akan otomatis memperbarui berkas setiap kali kita menekan tombol simpan (save)
rtk flutter pub run build_runner watch --delete-conflicting-outputs

Pendekatan 3: Freezed — Kelas Imutabel Terlengkap #

Meskipun json_serializable telah menyelesaikan masalah penulisan fromJson dan toJson, kita masih harus menulis metode copyWith, toString untuk pencatatan log debug, serta override operator == secara manual jika kita ingin menerapkan pola state management yang bersih.

Pustaka freezed hadir sebagai solusi terlengkap yang menggabungkan seluruh fitur kelas imutabel tingkat lanjut bersama dengan sistem serialisasi JSON otomatis dari json_serializable.

Konfigurasi Dependensi #

Tambahkan dependensi berikut ke berkas pubspec.yaml kita:

dependencies:
  freezed_annotation: ^2.4.4
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.13
  freezed: ^2.5.7
  json_serializable: ^6.8.0

Implementasi Kelas Model dengan Freezed #

Menulis model dengan Freezed menggunakan sintaksis yang sedikit berbeda karena memanfaatkan fitur mixin generated:

// produk.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'produk.freezed.dart';
part 'produk.g.dart'; // Tetap butuh berkas g.dart dari json_serializable

@freezed
class Produk with _$Produk {
  // Freezed menuntut kita mendefinisikan factory constructor utama
  const factory Produk({
    required String id,
    required String nama,
    required double harga,
    @Default(true) bool tersedia, // Menggunakan @Default untuk inisialisasi default
    @Default([]) List<String> kategori,
    @JsonKey(name: 'created_at') required DateTime tanggalDibuat,
  }) = _Produk;

  // Menghubungkan ke sistem deserialisasi JSON
  factory Produk.fromJson(Map<String, dynamic> json) => _$ProdukFromJson(json);
}

Setelah menjalankan perintah build_runner build, Freezed akan otomatis menghasilkan berkas pembantu berisi:

  1. Fungsi copyWith(): Memudahkan kita membuat salinan objek dengan mengubah sebagian field secara aman tipe data.
    final kopiMurah = produkKopi.copyWith(harga: 20000.0);
    
  2. Override operator == dan hashCode: Dua objek dengan isi field yang sama akan otomatis dievaluasi bernilai sama (true) oleh compiler.
  3. Implementasi toString(): Memudahkan kita membaca isi properti objek saat mencetaknya di log debugging.

Mengolah Objek Bersarang (Nested Objects) Secara Rekursif #

Aplikasi dunia nyata sering kali menerima struktur data JSON yang kompleks dengan objek di dalam objek lainnya (nested objects). Misalnya, objek data Pesanan yang di dalamnya mengandung objek Pelanggan dan daftar (list) objek ItemBelanja.

Untuk mengolah objek bersarang dengan pustaka generator, kita harus memastikan semua kelas anak telah dikonfigurasi menggunakan serialisasi otomatis juga. Selain itu, ada satu aturan krusial yang tidak boleh kita lewatkan.

Aturan explicitToJson: true #

Secara default, generator json_serializable hanya memanggil fungsi .toJson() pada tingkat terluar saja. Jika kelas utama kita mengandung objek bersarang, generator akan menghasilkan Map yang berisi instansi objek anak, bukan Map di dalam Map. Hal ini akan memicu error crash saat kita mencoba melakukan jsonEncode().

Kita harus mengonfigurasi generator agar memicu fungsi .toJson() secara rekursif ke seluruh anak-anaknya. Kita bisa menambahkannya di tingkat kelas, atau mengonfigurasinya secara global di root proyek menggunakan berkas build.yaml.

Berikut adalah contoh penulisan struktur berkas model bersarang menggunakan Freezed:

// 1. Model Pelanggan
@freezed
class Pelanggan with _$Pelanggan {
  const factory Pelanggan({
    required String id,
    required String nama,
    required String email,
  }) = _Pelanggan;

  factory Pelanggan.fromJson(Map<String, dynamic> json) => _$PelangganFromJson(json);
}

// 2. Model Item Belanja
@freezed
class ItemBelanja with _$ItemBelanja {
  const factory ItemBelanja({
    required String produkId,
    required String namaProduk,
    required double harga,
    required int kuantitas,
  }) = _ItemBelanja;

  factory ItemBelanja.fromJson(Map<String, dynamic> json) => _$ItemBelanjaFromJson(json);
}

// 3. Model Utama Pesanan (Mengandung objek bersarang & list objek)
@Freezed(toJson: true) // Memaksa generator menyertakan fungsi toJson rekursif
class Pesanan with _$Pesanan {
  const factory Pesanan({
    required String idPesanan,
    required Pelanggan pelanggan, // Objek bersarang
    required List<ItemBelanja> daftarBelanja, // List objek bersarang
    required DateTime tanggalTransaksi,
  }) = _Pesanan;

  factory Pesanan.fromJson(Map<String, dynamic> json) => _$PesananFromJson(json);
}

Union Types untuk Mengelola State Jaringan #

Salah satu fitur paling mutakhir yang disediakan oleh Freezed adalah Union Types (sering disebut sebagai Sealed Classes). Fitur ini memungkinkan kita membuat satu kelas induk yang merepresentasikan beberapa jenis variasi bentuk kelas anak yang saling eksklusif.

Konsep ini sangat ideal untuk merepresentasikan status data hasil panggilan jaringan API kita di layer UI:

// hasil_api.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'hasil_api.freezed.dart';

@freezed
class HasilApi<T> with _$HasilApi<T> {
  const factory HasilApi.memuat() = _Memuat;
  const factory HasilApi.sukses(T data) = _Sukses<T>;
  const factory HasilApi.gagal(String pesanError) = _Gagal<T>;
}

Mengonsumsi Union Types di UI Flutter #

Kita dapat memanfaatkan metode pattern matching when untuk mengurai status data secara aman di dalam method build widget kita:

class WidgetDaftarProduk extends StatelessWidget {
  final HasilApi<List<Produk>> state;

  const WidgetDaftarProduk({super.key, required this.state});

  @override
  Widget build(BuildContext context) {
    return state.when(
      memuat: () => const Center(child: CircularProgressIndicator()),
      sukses: (daftarProduk) {
        return ListView.builder(
          itemCount: daftarProduk.length,
          itemBuilder: (context, index) => ListTile(title: Text(daftarProduk[index].nama)),
        );
      },
      gagal: (pesanError) => Center(
        child: Text('Terjadi kesalahan: $pesanError', style: const TextStyle(color: Colors.red)),
      ),
    );
  }
}

Pattern matching memaksa kita menangani ketiga jenis status tersebut secara eksplisit di compiler, sehingga mencegah kita lupa menggambar layar loading atau menangani layar error.


Pola Generic API Response Wrapper #

Ketika kita berkomunikasi dengan REST API standar industri, server backend biasanya selalu mengembalikan respons dalam format amplop pembungkus (envelope response) yang seragam untuk semua endpoint, dan hanya isi kolom data-nya saja yang berubah-ubah secara dinamis.

Contoh format respons pembungkus API:

{
  "success": true,
  "message": "Data berhasil diambil",
  "data": {
    "id": "1",
    "nama": "Kursi Kerja"
  }
}

Menulis kelas respons terpisah untuk setiap tipe data (misal: UserResponse, ProductResponse, OrderResponse) akan melahirkan redundansi kode yang luar biasa. Kita dapat memecahkan masalah ini dengan menggunakan pola kelas generik menggunakan parameter anotasi genericArgumentFactories: true pada pustaka json_serializable.

Berikut adalah contoh pembuatan wrapper generik re-usable:

import 'package:json_annotation/json_annotation.dart';

part 'api_response.g.dart';

// Konfigurasi pabrik pembuat argumen generik
@JsonSerializable(genericArgumentFactories: true)
class ApiResponse<T> {
  final bool success;
  final String message;
  final T? data;

  const ApiResponse({
    required this.success,
    required this.message,
    this.data,
  });

  // Factory constructor menerima fungsi parser tambahan 'fromJsonT'
  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) => _$ApiResponseFromJson(json, fromJsonT);

  Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
      _$ApiResponseToJson(this, toJsonT);
}

Cara Membaca Respons Generik di Klien API kita: #

// A. Parsing data berjenis Objek Tunggal
final Map<String, dynamic> jsonMapTunggal = jsonDecode(rawResponse1);
final responseUser = ApiResponse<User>.fromJson(
  jsonMapTunggal,
  (json) => User.fromJson(json as Map<String, dynamic>),
);
print('Halo ${responseUser.data?.username}');

// B. Parsing data berjenis Daftar Objek (List)
final Map<String, dynamic> jsonMapList = jsonDecode(rawResponse2);
final responseProdukList = ApiResponse<List<Produk>>.fromJson(
  jsonMapList,
  (json) => (json as List).map((item) => Produk.fromJson(item as Map<String, dynamic>)).toList(),
);
print('Total produk: ${responseProdukList.data?.length}');

JsonConverter untuk Konversi Tipe Data Kustom #

Terkadang kita dihadapkan pada situasi di mana format data yang dikirimkan oleh server backend tidak cocok dengan tipe data yang ingin kita gunakan di Dart. Beberapa kasus ketidakcocokan data yang sering terjadi meliputi:

  • Server mengirimkan harga dalam bentuk String "125000", sedangkan kita menginginkannya sebagai double di Flutter.
  • Server mengirimkan status pesanan dalam format angka integer 1, 2, atau 3, sedangkan kita ingin memetakan angka tersebut menjadi kelas enum StatusPesanan agar lebih bermakna di kode UI kita.

Kita dapat menjembatani perbedaan tipe data ini secara bersih menggunakan kelas khusus JsonConverter<T, S> (di mana T adalah tipe data target di Dart kita, dan S adalah tipe data asal dari JSON server).

1. Membuat Converter String ke Double #

import 'package:json_annotation/json_annotation.dart';

class StringToDoubleConverter implements JsonConverter<double, dynamic> {
  const StringToDoubleConverter();

  @override
  double fromJson(dynamic json) {
    if (json is num) return json.toDouble();
    // Jika data berupa string, lakukan parsing manual secara aman
    return double.tryParse(json.toString()) ?? 0.0;
  }

  @override
  dynamic toJson(double object) => object;
}

2. Membuat Converter Integer ke Enum #

enum StatusPesanan { menunggu, diproses, dikirim, selesai }

class StatusPesananConverter implements JsonConverter<StatusPesanan, int> {
  const StatusPesananConverter();

  @override
  StatusPesanan fromJson(int json) {
    // Memastikan index tidak di luar batas jangkauan enum
    if (json >= 0 && json < StatusPesanan.values.length) {
      return StatusPesanan.values[json];
    }
    return StatusPesanan.menunggu;
  }

  @override
  int toJson(StatusPesanan object) => object.index;
}

3. Menerapkan Converter ke Model Kelas Kita #

Kita cukup menyisipkan anotasi converter kustom kita tepat di atas properti target:

@freezed
class Transaksi with _$Transaksi {
  const factory Transaksi({
    required String idTransaksi,
    
    // Menerapkan converter kustom untuk konversi otomatis saat parsing
    @StringToDoubleConverter() required double nominalTransfer,
    @StatusPesananConverter() required StatusPesanan status,
  }) = _Transaksi;

  factory Transaksi.fromJson(Map<String, dynamic> json) => _$TransaksiFromJson(json);
}

Perbandingan Head-to-Head Tiga Pendekatan #

Untuk memudahkan kita menentukan mana pendekatan terbaik yang perlu disepakati di dalam panduan standardisasi kode tim kita, berikut adalah matriks perbandingannya:

Fitur / ParameterParsing Manual (dart:convert)json_serializableFreezed (Rekomendasi Utama)
Ketergantungan EksternalTidak Ada (Bawaan SDK Dart)Memerlukan setup generatorMemerlukan setup generator lengkap
Waktu Setup AwalInstan / Tanpa PersiapanMemerlukan instalasi packagesMemerlukan instalasi packages lengkap
Peluang Kesalahan TypoSangat TinggiSangat RendahSangat Rendah
Pembuatan copyWithHarus ditulis manualTidak DisediakanDibuat otomatis
Pembandingan Objek (==)Harus ditulis manualTidak DisediakanDibuat otomatis (berbasis nilai)
Dukungan Union TypesTidak AdaTidak AdaAda secara native
Skalabilitas ProyekSangat BurukBaikSangat Baik

Sebagai kesimpulan praktis, gunakan Parsing Manual jika kita hanya menulis kelas data yang sangat sedikit (misal 1-2 kelas) pada proyek sampingan kecil yang tidak memerlukan pemeliharaan jangka panjang. Gunakan Freezed sebagai standar wajib untuk seluruh proyek komersial skala produksi demi menjamin keamanan tipe data, kestabilan state, kemudahan debugging, dan produktivitas tim yang maksimal.

Ringkasan #

  • Deserialisasi adalah proses penguraian data teks string JSON mentah dari server API menjadi instansi objek kelas Dart bertipe data aman (type-safe).
  • Parsing Manual menggunakan jsonDecode dari pustaka dart:convert sangat rawan typo dan tidak memiliki skalabilitas yang baik untuk proyek jangka panjang.
  • json_serializable menyingkirkan kode boilerplate konversi dengan menghasilkan berkas helper .g.dart secara otomatis melalui pembacaan anotasi kelas.
  • Freezed merupakan pustaka kelas imutabel modern yang menghasilkan fungsi fromJson/toJson, copyWith(), override ==, dan toString() secara otomatis.
  • Nested Object membutuhkan konfigurasi @Freezed(toJson: true) atau pengaturan global explicit_to_json: true agar konversi rekursif berjalan lancar.
  • Union Types dari Freezed mempermudah pengorganisasian status loading/sukses/gagal secara deklaratif dan memaksa penanganan visual lengkap di UI.
  • Generic API Response memangkas duplikasi berkas wrapper respons server menggunakan fitur genericArgumentFactories secara dinamis.
  • JsonConverter memfasilitasi penyesuaian tipe data mentah yang aneh dari server (seperti angka berformat string atau penunjuk enum integer) ke tipe data yang sesuai kebutuhan kita di Dart.
  • Watch Mode: Selalu gunakan perintah build_runner watch selama masa pengembangan agar generator berjalan di latar belakang secara otomatis ketika berkas disimpan.

← Sebelumnya: Dio & HTTP   Berikutnya: Repository Pattern →

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