Kodokon kodokon.com

Modeling your data: classes, JSON, and immutability

Turn JSON into immutable Dart classes with fromJson, toJson, copyWith, and reliable value equality.

9 min · 3 questions

Open this lesson in Kodokon

At this level, the real subject is no longer class syntax but the boundary between the outside world and your code. As long as an API response travels around as a Map<String, dynamic>, every field read is a runtime gamble. The professional rule: convert JSON into typed objects the moment you receive it, then pass only those objects around. Combine this with immutability - final fields, const constructor - and you eliminate a whole family of bugs: nobody can modify a shared object behind your back anymore, and two identical const instances are canonicalized by the compiler.

DART
class User {
  const User({
    required this.id,
    required this.name,
    this.email,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: (json['id'] as num).toInt(),
      name: json['name'] as String,
      email: json['email'] as String?,
    );
  }

  final int id;
  final String name;
  final String? email;
}
An immutable model with defensive JSON parsing.

Three choices deserve to be spelled out. First, (json['id'] as num).toInt(): many APIs serialize an integer sometimes as 3, sometimes as 3.0 depending on the backend; a direct cast to int crashes on a double. Next, as String fails immediately with a clear TypeError if the field is missing: that's intentional. Failing fast at parse time beats a null that propagates and blows up three screens later. Finally, as String? documents in the type that the field really is optional.

DART
class User {
  const User({required this.id, this.email});

  final int id;
  final String? email;

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

  User copyWith({int? id, String? email}) {
    return User(
      id: id ?? this.id,
      email: email ?? this.email,
    );
  }
}
toJson and copyWith on a model trimmed down to two fields.

Immutability calls for value equality. By default, Dart compares references: two field-for-field identical Users are considered "different." That's a concrete problem the moment you compare states - in tests, in Provider's select, in a list search. Redefine == and hashCode together, always.

DART
class Point {
  const Point(this.x, this.y);

  final int x;
  final int y;

  @override
  bool operator ==(Object other) =>
      other is Point && other.x == x && other.y == y;

  @override
  int get hashCode => Object.hash(x, y);
}
Value equality: == and hashCode come as a pair.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why write (json['id'] as num).toInt() rather than json['id'] as int?
    • Because as int is slower than as num at runtime.
    • Because a backend can serialize an integer as 3.0: a direct cast to int would fail on a double.
    • Because json['id'] is always a string.
  2. What does user.copyWith(email: null) produce with the classic copyWith implementation?
    • The email field is reset to null as requested.
    • An exception is thrown at runtime.
    • The object keeps its current email: null ?? this.email falls back to the existing value.
    • The code doesn't compile in strict mode.
  3. Why redefine == and hashCode on an immutable model?
    • Because Dart compares references by default: two field-for-field identical objects would otherwise be "different."
    • To speed up the creation of const instances.
    • It's mandatory as soon as a field is declared final.