Flutter: How to (de)serialize JSON Properly
A definitive guide to clean model generation that AI agents keep getting wrong.
Flutter: How to (de)serialize JSON Properly
A definitive guide to clean model generation that AI agents keep getting wrong.

If you are a member, please continue; **otherwise, read the full story here.**
If you’ve used agentic IDEs like Cursor, Trae, or Windsurf to generate Dart models, you’ve likely encountered these familiar mistakes:
// ❌ WRONG: What agents typically generate
class Todo {
final String id;
Todo({required this.id});
// Inconsistent naming - takes a Map but calls it 'json'
factory Todo.fromJson(Map<String, dynamic> json) => Todo(
id: json['id'],
);
// Uses json.encode instead of jsonEncode
String toJson() => json.encode(toMap());
Map<String, dynamic> toMap() => {'id': id};
}
Three mistakes in one model:
- Wrong JSON function: Uses
json.encode()instead ofjsonEncode(). - Confused naming: The parameter is a
Map<String, dynamic>but it's calledjson—jsonshould be aString, not aMap. - Mixed conventions:
fromJsontakes a Map, buttoJsonreturns a String. This asymmetry is confusing and error-prone.
These mistakes propagate because agents learn from existing codebases — and most codebases have these same errors. It’s a vicious cycle of technical debt.
Here’s the correct implementation that every Dart model should follow:
import 'dart:convert' hide json;
import 'package:equatable/equatable.dart';
class Todo extends Equatable {
final String id;
final String title;
final bool isDone;
const Todo({
required this.id,
required this.title,
required this.isDone,
});
// ─────────────────────────────────────────────
// IMMUTABILITY & VALUE EQUALITY
// ─────────────────────────────────────────────
Todo copyWith({
String? id,
String? title,
bool? isDone,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
isDone: isDone ?? this.isDone,
);
}
// ─────────────────────────────────────────────
// (DE)SERIALIZATION: String ↔ Object
// ─────────────────────────────────────────────
/// Deserializes a JSON **String** into a Todo instance
factory Todo.fromJson(String json) =>
Todo.fromMap(jsonDecode(json) as Map<String, dynamic>);
/// Serializes this Todo into a JSON **String**
String toJson() => jsonEncode(toMap());
// ─────────────────────────────────────────────
// MAPPING: Map ↔ Object
// ─────────────────────────────────────────────
/// Creates Todo from a **Map** representation
factory Todo.fromMap(Map<String, dynamic> map) => Todo(
id: map['id'] as String,
title: map['title'] as String,
isDone: map['isDone'] as bool,
);
/// Converts this Todo into a **Map** representation
Map<String, dynamic> toMap() => {
'id': id,
'title': title,
'isDone': isDone,
};
// ─────────────────────────────────────────────
// EQUATABLE: Value-based equality
// ─────────────────────────────────────────────
@override
List<Object?> get props => [id, title, isDone];
@override
bool get stringify => true;
}
Rule #1: Use jsonEncode / jsonDecode, not json.encode / json.decode
Dart provides two ways to access the same functions:
import 'dart:convert';
json.encode(data);
json.decode(json);
jsonEncode(data);
jsonDecode(json);
Why jsonEncode is superior:
- No shadowing risk: If a local variable named
jsonexists in your scope,json.encode()breaks.jsonEncode()always works. - Community standard: Modern Flutter documentation and libraries use the top-level functions.
The implementation is identical — jsonEncode() literally just calls json.encode(). The difference is purely about code safety and readability.
Rule #2: Establish naming conventions

fromJson / toJson work with Strings.
fromMap / toMap work with Maps.
Rule #3: Use Equatable
The Equatable package solves a fundamental Dart problem: reference equality vs. value equality.
The Problem: By default, Dart compares objects by memory address:
class Todo {
final String id;
Todo(this.id);
}
void main() {
final a = Todo('1');
final b = Todo('1');
print(a == b); // false ❌ Same data, different memory addresses
}
This breaks state management in Bloc, Riverpod, or any reactive system. For example, emit(NewState()) triggers rebuild if previous != current. With reference equality, identical data causes unnecessary rebuilds.
The Solution:Equatable overrides == and hashCode to compare property values instead of memory addresses :
class Todo extends Equatable {
final String id;
const Todo(this.id);
@override
List<Object?> get props => [id]; // Compare these fields
}
void main() {
final a = Todo('1');
final b = Todo('1');
print(a == b); // true ✅ Value-based equality
// Also works in collections
final set = {a};
print(set.contains(b)); // true
}
Benefits:
- Efficient state management: No unnecessary widget rebuilds when data hasn’t changed.
- Collection operations: Sets and Maps work correctly with value semantics.
- Test assertions:
expect(actual, expected)works without custom matchers. - No boilerplate: Manual
==andhashCodeoverrides are ~20 lines;Equatableis 3 lines.
Setting stringify => true provides clean debug output:
@override
bool get stringify => true;
// Without stringify: Instance of 'Todo'
// With stringify: Todo(id: 1, title: "Buy milk", isDone: false)
That’s it.
Bonus: dart-models skill.
Thank you for reading!
메타데이터
- post_id
- da7fdd56ae7d
- slug
- flutter-how-to-de-serialize-json-properly-da7fdd56ae7d
- url
- https://medium.com/easy-flutter/flutter-how-to-de-serialize-json-properly-da7fdd56ae7d
- canonical_url
- https://medium.com/easy-flutter/flutter-how-to-de-serialize-json-properly-da7fdd56ae7d
- author_url
- https://medium.com/@yurinovicow
- status
- ok
- fetched_at
- 2026-07-31 12:13:25