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 #
jsonDecodereturnsdynamic— always cast to the expected type (as Map<String, dynamic>,as List<dynamic>) before accessing fields.- The
fromJson/toJsonpattern is the Dart standard for model serialization — parse immediately after receiving network data, don’t passMap<String, dynamic>around the whole codebase.(json['harga'] as num).toDouble()for numeric fields —numis the supertype ofintanddouble, 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_serializablefor large projects — annotate with@JsonSerializable()and rundart run build_runner buildto generate type-safefromJson/toJsonwithout manual boilerplate.@JsonKey(name: 'snake_case')to map JSON field names that differ from Dart field names, or usefieldRename: FieldRename.snakeglobally.- Custom encoders with
toEncodablefor non-standard types likeDateTime,Enum, andUrithat JSON doesn’t support natively.- Streaming for large JSON — NDJSON (one JSON per line) can be processed with
LineSplitterwithout loading the entire file into memory.FormatExceptionis the specific exception fromjsonDecodefor invalid JSON — catch it specifically, not a genericcatch (e).JsonEncoder.withIndent(' ')for human-readable JSON formatting when debugging — don’t use it in production responses to save bandwidth.