JSON & Serialisasi #

Hampir semua data yang diterima dari API berbentuk JSON. Konversi antara JSON dan Dart object — yang disebut serialisasi dan deserialisasi — adalah pekerjaan yang berulang dan rawan kesalahan jika dilakukan secara manual. Flutter menyediakan beberapa pendekatan, dari parsing manual yang sederhana hingga code generation yang aman dan otomatis.

Parsing Manual — dart:convert #

Pendekatan paling dasar: gunakan jsonDecode() dari package dart:convert bawaan Dart, lalu petakan hasilnya ke class Dart 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,
  });

  // Deserialisasi: JSON → Produk
  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(),
      tersedia: json['tersedia'] as bool,
      kategori: (json['kategori'] as List<dynamic>)
          .map((e) => e as String)
          .toList(),
    );
  }

  // Serialisasi: Produk → JSON
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'nama': nama,
      'harga': harga,
      'tersedia': tersedia,
      'kategori': kategori,
    };
  }
}

// Parsing satu objek
final Map<String, dynamic> json = jsonDecode(responseBody);
final produk = Produk.fromJson(json);

// Parsing list
final List<dynamic> jsonList = jsonDecode(responseBody);
final produkList = jsonList.map((j) => Produk.fromJson(j)).toList();

// Encode ke JSON string
final jsonString = jsonEncode(produk.toJson());

Keterbatasan Parsing Manual #

✗ Repetitif -- harus tulis fromJson/toJson untuk setiap class
✗ Rawan typo -- 'nama' vs 'name', tidak ada compile-time check
✗ Tidak ada == override -- dua objek dengan nilai sama dianggap berbeda
✗ Tidak ada copyWith -- harus tulis sendiri
✗ Tidak ada toString yang informatif untuk debugging
✗ Skala buruk -- 20 class = 20x kode boilerplate yang sama

json_serializable — Code Generation #

json_serializable menghasilkan kode fromJson dan toJson secara otomatis dari anotasi — menghilangkan boilerplate dan mengurangi risiko kesalahan ketik.

Instalasi #

# pubspec.yaml
dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.13
  json_serializable: ^6.8.0

Definisi Model #

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

part 'produk.g.dart';  // file yang akan di-generate

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

  // @JsonKey: peta nama field JSON yang berbeda dengan nama field Dart
  @JsonKey(name: 'created_at')
  final DateTime createdAt;

  // defaultValue: nilai default jika key tidak ada di JSON
  @JsonKey(defaultValue: false)
  final bool isFeatured;

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

  // fromJson dan toJson di-generate otomatis
  factory Produk.fromJson(Map<String, dynamic> json) => _$ProdukFromJson(json);
  Map<String, dynamic> toJson() => _$ProdukToJson(this);
}

Jalankan Code Generation #

# Generate sekali
flutter pub run build_runner build --delete-conflicting-outputs

# Watch mode -- auto-regenerate saat file berubah
flutter pub run build_runner watch --delete-conflicting-outputs

Perintah ini menghasilkan produk.g.dart berisi implementasi _$ProdukFromJson dan _$ProdukToJson.

@JsonKey — Kustomisasi Field #

@JsonSerializable()
class UserProfile {
  // Nama field berbeda antara JSON dan Dart
  @JsonKey(name: 'full_name')
  final String fullName;

  // Ignore field ini saat serialisasi/deserialisasi
  @JsonKey(includeFromJson: false, includeToJson: false)
  final bool isSelected;

  // Nilai default jika key tidak ada
  @JsonKey(defaultValue: 0)
  final int loginCount;

  // Custom converter untuk tipe yang tidak didukung langsung
  @JsonKey(fromJson: _parseDate, toJson: _formatDate)
  final DateTime createdAt;

  static DateTime _parseDate(String date) => DateTime.parse(date);
  static String _formatDate(DateTime date) => date.toIso8601String();

  const UserProfile({
    required this.fullName,
    this.isSelected = false,
    this.loginCount = 0,
    required this.createdAt,
  });

  factory UserProfile.fromJson(Map<String, dynamic> json) =>
      _$UserProfileFromJson(json);
  Map<String, dynamic> toJson() => _$UserProfileToJson(this);
}

Freezed — Model Immutable dengan Semua Fitur #

Freezed menggabungkan kekuatan json_serializable dengan fitur class immutable yang lengkap: copyWith, == override, hashCode, toString, dan union types.

Instalasi #

# pubspec.yaml
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

Model Dasar dengan Freezed #

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

part 'produk.freezed.dart';
part 'produk.g.dart';

@freezed
class Produk with _$Produk {
  const factory Produk({
    required String id,
    required String nama,
    required double harga,
    @Default(true) bool tersedia,
    @Default([]) List<String> kategori,
    @JsonKey(name: 'created_at') required DateTime createdAt,
  }) = _Produk;

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

// Freezed menghasilkan secara otomatis:
// - copyWith()
// - == dan hashCode
// - toString()
// - fromJson / toJson

// Penggunaan
final produk = Produk(
  id: '1',
  nama: 'Flutter Book',
  harga: 150000,
  createdAt: DateTime.now(),
);

// copyWith -- buat salinan dengan beberapa field diubah
final produkDiskon = produk.copyWith(harga: 120000);

// == bekerja berdasarkan nilai
print(produk == produk.copyWith());  // true!

Nested Object #

@freezed
class Pesanan with _$Pesanan {
  const factory Pesanan({
    required String id,
    required Pelanggan pelanggan,      // nested Freezed object
    required List<ItemPesanan> items,  // list of Freezed objects
    required AlamatPengiriman alamat,
    required StatusPesanan status,     // enum
  }) = _Pesanan;

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

@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);
}

enum StatusPesanan { menunggu, diproses, dikirim, selesai, dibatalkan }

Penting untuk nested Freezed objects: Tambahkan @JsonSerializable(explicitToJson: true) jika Freezed class mengandung nested Freezed object, agar toJson() memanggil toJson() pada nested object secara rekursif.

@Freezed(toJson: true)
// atau gunakan build.yaml:
// targets:
//   $default:
//     builders:
//       json_serializable:
//         options:
//           explicit_to_json: true

Union Types — Satu Class, Banyak Bentuk #

Freezed memungkinkan satu class merepresentasikan beberapa kemungkinan state — sangat berguna untuk state networking:

@freezed
class HasilApi<T> with _$HasilApi<T> {
  const factory HasilApi.sukses(T data) = _Sukses;
  const factory HasilApi.error(String pesan, int? statusCode) = _Error;
  const factory HasilApi.loading() = _Loading;
}

// Penggunaan dengan pattern matching
final hasil = HasilApi<List<Produk>>.sukses(produkList);

hasil.when(
  sukses: (data) => print('${data.length} produk'),
  error: (pesan, code) => print('Error $code: $pesan'),
  loading: () => print('Memuat...'),
);

// maybeWhen -- hanya handle beberapa case
hasil.maybeWhen(
  sukses: (data) => print('Sukses!'),
  orElse: () => print('Bukan sukses'),
);

Generic API Response #

Server sering mengembalikan response dengan struktur yang seragam, hanya data-nya yang berbeda. Pola generic response menghindari duplikasi:

// Struktur umum API response:
// {
//   "success": true,
//   "message": "OK",
//   "data": { ... }  // atau [ ... ]
// }

@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 ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,  // converter untuk tipe T
  ) => _$ApiResponseFromJson(json, fromJsonT);

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

// Penggunaan
final response = ApiResponse<Produk>.fromJson(
  jsonDecode(responseBody),
  (json) => Produk.fromJson(json as Map<String, dynamic>),
);

final responseList = ApiResponse<List<Produk>>.fromJson(
  jsonDecode(responseBody),
  (json) => (json as List).map((e) => Produk.fromJson(e)).toList(),
);

// Mengakses data
if (response.success && response.data != null) {
  final produk = response.data!;
}

JsonConverter — Konversi Tipe Kustom #

JsonConverter berguna saat server mengirim tipe yang tidak cocok langsung dengan Dart:

// Server kirim: "harga": "150000" (String, bukan double)
// Kita butuh: double

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

  @override
  double fromJson(dynamic json) {
    if (json is num) return json.toDouble();
    return double.parse(json.toString());
  }

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

// Server kirim: "status": 1 (int)
// Kita butuh: enum StatusPesanan

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

  @override
  StatusPesanan fromJson(int json) => StatusPesanan.values[json];

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

// Penggunaan di model
@freezed
class Produk with _$Produk {
  const factory Produk({
    @StringToDoubleConverter() required double harga,
    @StatusConverter() required StatusPesanan status,
  }) = _Produk;

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

Perbandingan Pendekatan #

Manualjson_serializableFreezed
SetupTidak perlubuild_runnerbuild_runner
BoilerplateBanyakMinimalMinimal
fromJson/toJsonManualGeneratedGenerated
copyWithManualTidak adaGenerated
== dan hashCodeManualTidak adaGenerated
toStringManualTidak adaGenerated
Union typesTidak adaTidak adaAda
TestabilityBaikBaikSangat Baik
Cocok untukPrototipeModel sederhanaSemua skenario

Ringkasan #

  • Parsing manual dengan dart:convert cukup untuk prototipe atau beberapa class sederhana — tapi tidak skala untuk aplikasi yang besar.
  • json_serializable menghasilkan fromJson/toJson secara otomatis — menghilangkan boilerplate dan mencegah typo. Gunakan @JsonKey untuk memetakan nama field yang berbeda antara JSON dan Dart.
  • Freezed adalah pilihan terlengkap — ia menghasilkan fromJson, toJson, copyWith, ==, hashCode, toString, dan mendukung union types. Ini adalah standar industri untuk model di Flutter produksi.
  • Gunakan @Default di Freezed untuk nilai default field opsional, sehingga JSON yang tidak mengandung field tersebut tetap bisa diparsing.
  • Untuk nested Freezed objects, pastikan explicitToJson: true agar toJson() memanggil toJson() pada objek nested secara rekursif.
  • JsonConverter menangani kasus di mana tipe dari server tidak cocok langsung dengan tipe Dart — misalnya angka yang dikirim sebagai String, atau enum yang dikirim sebagai integer.
  • Selalu jalankan build_runner setelah mengubah class yang menggunakan anotasi. Gunakan watch mode selama development.

← Sebelumnya: Dio & HTTP   Berikutnya: Repository Pattern →

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