DevToolBoxGRATUIT
Blog

JSON vers Dart: Guide Complet de Génération de Classes Flutter avec Null Safety

11 min de lecturepar DevToolBox
TL;DR

Converting JSON to Dart classes with null safety is essential for robust Flutter apps. Use our free online tool for instant generation, or the json_serializable package for production codebases. Always distinguish between required fields, optional fields (?), and nullable fields (Type?). The freezed package adds immutability and copyWith(). Run `flutter pub run build_runner build` after using annotations.

Key Takeaways
  • Dart null safety requires you to mark nullable fields with ? and use the required keyword for mandatory constructor parameters.
  • json_serializable + build_runner eliminates fromJson/toJson boilerplate — annotate with @JsonSerializable() and run code generation.
  • freezed creates immutable data classes with copyWith(), == overrides, and pattern matching on top of json_serializable.
  • Nested objects need their own fromJson factories; use List<T>.from(json['key'].map((x) => T.fromJson(x))) for typed lists.
  • DateTime parsing uses DateTime.parse() in fromJson and .toIso8601String() in toJson.
  • For enums, use @JsonKey(unknownEnumValue: EnumType.unknown) to handle values not yet in your enum definition.
  • Always write a roundtrip unit test: expect(User.fromJson(user.toJson()), equals(user)) to catch serialization bugs early.
Try our free JSON to Dart converter

Why Convert JSON to Dart Classes?

Flutter apps communicate with REST APIs, WebSockets, and local storage using JSON. Without strongly typed Dart classes, you work with dynamic maps — no autocomplete, no compile-time checks, and runtime exceptions waiting to happen. Dart's sound null safety makes this even more important: the compiler enforces correct handling of nullable values.

Generating Dart model classes from JSON gives you:

  • Compile-time type safety: Access a missing property and the analyzer catches it immediately.
  • IDE autocomplete: Every field, method, and nested object is discoverable via IntelliSense.
  • Null safety enforcement: Dart separates nullable (String?) from non-nullable (String) at the language level.
  • Refactoring confidence: Rename a field in your class and the analyzer flags every usage site.
  • Testability: Immutable value objects are easy to construct, compare, and assert in unit tests.

Dart Class Anatomy: fromJson and toJson

A canonical Dart model class has three parts: fields (with null safety annotations), a factory constructor for deserialization, and a method for serialization:

class User {
  final int id;
  final String name;
  final String? email;       // nullable — may be null
  final DateTime createdAt;

  const User({
    required this.id,
    required this.name,
    this.email,              // optional parameter, defaults to null
    required this.createdAt,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String?,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'created_at': createdAt.toIso8601String(),
    };
  }
}

Key design decisions in this class:

  • final fields make the class effectively immutable after construction.
  • required in the constructor enforces that callers always provide non-nullable fields.
  • The factory constructor casts each JSON value to its Dart type, converting snake_case keys to camelCase fields.
  • String? (nullable String) maps to JSON values that can be null or absent.

Null Safety in Generated Classes

Dart 2.12+ enforces sound null safety. This means the type system tracks which values can be null and which cannot. When generating classes from JSON, you must correctly identify nullable vs non-nullable fields:

// Non-nullable: field is always present and never null
final String name;           // json['name'] is always a String

// Nullable: field may be absent or explicitly null
final String? bio;           // json['bio'] can be null

// Null assertion: you're sure the value isn't null at runtime
final String admin = json['role']!;  // throws if null!

// Null coalescing: provide a default when the value is null
final int age = json['age'] ?? 0;    // defaults to 0 if absent

Best practice: prefer providing explicit defaults over using the null assertion operator (!). The assertion can throw a Null check operator used on a null value exception at runtime.

factory UserProfile.fromJson(Map<String, dynamic> json) {
  return UserProfile(
    id: json['id'] as int,
    username: json['username'] as String,
    // Nullable field — may be null or missing
    avatarUrl: json['avatar_url'] as String?,
    // Default value instead of null assertion
    followersCount: (json['followers_count'] as int?) ?? 0,
    // Boolean with default
    isVerified: (json['is_verified'] as bool?) ?? false,
  );
}

json_serializable Package

For projects with many models, the json_serializable package eliminates repetitive fromJson/toJson boilerplate through code generation. Setup involves three steps:

Step 1: pubspec.yaml Setup

dependencies:
  flutter:
    sdk: flutter
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0

Step 2: Annotate Your Model

import 'package:json_annotation/json_annotation.dart';

part 'product.g.dart';     // generated file

@JsonSerializable()
class Product {
  final int id;
  final String name;
  @JsonKey(name: 'unit_price')
  final double unitPrice;
  @JsonKey(defaultValue: true)
  final bool inStock;

  const Product({
    required this.id,
    required this.name,
    required this.unitPrice,
    required this.inStock,
  });

  // Generated code lives in product.g.dart:
  factory Product.fromJson(Map<String, dynamic> json) =>
      _$ProductFromJson(json);

  Map<String, dynamic> toJson() => _$ProductToJson(this);
}

Step 3: Run Build Runner

# One-time generation
flutter pub run build_runner build

# Watch mode — regenerates on file save
flutter pub run build_runner watch --delete-conflicting-outputs

After running build_runner, a product.g.dart file is created with the full fromJson and toJson implementations. You never edit this file — always edit the annotated class and regenerate.

Useful @JsonKey options:

  • @JsonKey(name: 'snake_key') — maps a JSON key to a differently-named Dart field.
  • @JsonKey(defaultValue: 0) — provides a default when the key is absent.
  • @JsonKey(ignore: true) — excludes a field from serialization.
  • @JsonKey(fromJson: _parseDate, toJson: _formatDate) — custom conversion functions.

freezed Package: Immutable Data Classes

The freezed package builds on json_serializable to create fully immutable Dart classes with value equality, copyWith(), and sealed class support. It's the preferred choice for state management models in Riverpod and BLoC:

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0
  freezed: ^2.4.0

dependencies:
  freezed_annotation: ^2.4.0
  json_annotation: ^4.8.1
import 'package:freezed_annotation/freezed_annotation.dart';

part 'order.freezed.dart';
part 'order.g.dart';

@freezed
class Order with _$Order {
  const factory Order({
    required int id,
    required String status,
    required List<OrderItem> items,
    double? discount,
    @Default(false) bool isPriority,
  }) = _Order;

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

Benefits of freezed:

  • Value equality: Two Order objects with the same fields are == without writing hashCode or ==.
  • copyWith(): Create modified copies easily — order.copyWith(status: 'shipped').
  • @Default: Declare default field values inline.
  • Sealed classes / Union types: Model states like Loading | Data | Error with pattern matching.
@freezed
sealed class ApiState<T> with _$ApiState<T> {
  const factory ApiState.loading() = _Loading;
  const factory ApiState.data(T value) = _Data;
  const factory ApiState.error(String message) = _Error;
}

// Pattern matching usage:
Widget build(BuildContext context, ApiState<User> state) {
  return state.when(
    loading: () => const CircularProgressIndicator(),
    data: (user) => Text(user.name),
    error: (msg) => Text('Error: $msg'),
  );
}

Manual fromJson/toJson Patterns

Sometimes you need full control over deserialization. Here are common manual patterns:

DateTime Parsing

factory Event.fromJson(Map<String, dynamic> json) {
  return Event(
    id: json['id'] as int,
    title: json['title'] as String,
    // ISO 8601 string to DateTime
    startTime: DateTime.parse(json['start_time'] as String),
    // Nullable DateTime
    endTime: json['end_time'] != null
        ? DateTime.parse(json['end_time'] as String)
        : null,
    // Unix timestamp to DateTime
    updatedAt: DateTime.fromMillisecondsSinceEpoch(
      (json['updated_at'] as int) * 1000,
    ),
  );
}

Map<String, dynamic> toJson() => {
  'id': id,
  'title': title,
  'start_time': startTime.toIso8601String(),
  'end_time': endTime?.toIso8601String(),
  'updated_at': updatedAt.millisecondsSinceEpoch ~/ 1000,
};

Map Fields

factory Config.fromJson(Map<String, dynamic> json) {
  return Config(
    settings: (json['settings'] as Map<String, dynamic>)
        .map((k, v) => MapEntry(k, v as String)),
    metadata: json['metadata'] as Map<String, dynamic>? ?? {},
  );
}

Nested Objects and Lists

Most real-world JSON has nested objects and arrays. Here's how to handle them correctly:

// JSON structure:
// { "post": { "id": 1, "tags": ["dart", "flutter"], "author": { "id": 10, "name": "Alice" } } }

class Author {
  final int id;
  final String name;

  Author({required this.id, required this.name});

  factory Author.fromJson(Map<String, dynamic> json) =>
      Author(id: json['id'] as int, name: json['name'] as String);

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

class Post {
  final int id;
  final List<String> tags;
  final Author author;

  Post({required this.id, required this.tags, required this.author});

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'] as int,
      // List<String> from JSON array
      tags: List<String>.from(json['tags'] as List),
      // Nested object
      author: Author.fromJson(json['author'] as Map<String, dynamic>),
    );
  }

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

For lists of complex objects:

factory Cart.fromJson(Map<String, dynamic> json) {
  return Cart(
    id: json['id'] as int,
    // List of complex objects
    items: (json['items'] as List)
        .map((item) => CartItem.fromJson(item as Map<String, dynamic>))
        .toList(),
    // Nullable list
    discounts: json['discounts'] != null
        ? (json['discounts'] as List)
            .map((d) => Discount.fromJson(d as Map<String, dynamic>))
            .toList()
        : null,
  );
}

Enum Serialization

Serializing Dart enums to and from JSON strings requires care — especially when the API may return values you haven't defined yet:

enum OrderStatus {
  @JsonValue('pending')
  pending,
  @JsonValue('processing')
  processing,
  @JsonValue('shipped')
  shipped,
  @JsonValue('delivered')
  delivered,
  @JsonValue('cancelled')
  cancelled,
}

With json_serializable, use @JsonKey(unknownEnumValue:) to handle unknown values gracefully:

@JsonSerializable()
class Order {
  final int id;
  @JsonKey(unknownEnumValue: OrderStatus.pending)
  final OrderStatus status;

  Order({required this.id, required this.status});
  factory Order.fromJson(Map<String, dynamic> json) =>
      _$OrderFromJson(json);
  Map<String, dynamic> toJson() => _$OrderToJson(this);
}

For manual serialization, a helper extension works well:

extension OrderStatusExtension on String {
  OrderStatus toOrderStatus() {
    switch (this) {
      case 'processing': return OrderStatus.processing;
      case 'shipped': return OrderStatus.shipped;
      case 'delivered': return OrderStatus.delivered;
      case 'cancelled': return OrderStatus.cancelled;
      default: return OrderStatus.pending;
    }
  }
}

// Usage in fromJson:
status: (json['status'] as String).toOrderStatus(),

Date and Time Handling

DateTime handling is one of the trickiest parts of JSON serialization. Here are common patterns and a reusable custom converter:

// Custom JsonConverter for DateTime
class DateTimeConverter implements JsonConverter<DateTime, String> {
  const DateTimeConverter();

  @override
  DateTime fromJson(String json) => DateTime.parse(json);

  @override
  String toJson(DateTime object) => object.toIso8601String();
}

// Custom converter for Unix timestamps
class TimestampConverter implements JsonConverter<DateTime, int> {
  const TimestampConverter();

  @override
  DateTime fromJson(int json) =>
      DateTime.fromMillisecondsSinceEpoch(json * 1000);

  @override
  int toJson(DateTime object) =>
      object.millisecondsSinceEpoch ~/ 1000;
}

// Apply to your model:
@JsonSerializable()
class Article {
  final int id;
  @DateTimeConverter()
  final DateTime publishedAt;
  @TimestampConverter()
  final DateTime updatedAt;

  Article({
    required this.id,
    required this.publishedAt,
    required this.updatedAt,
  });

  factory Article.fromJson(Map<String, dynamic> json) =>
      _$ArticleFromJson(json);
  Map<String, dynamic> toJson() => _$ArticleToJson(this);
}

Network Requests with Dio and http

Combining JSON parsing with HTTP clients is the most common Flutter use case. Here's how to fetch and parse JSON safely:

Using the http Package

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<User> fetchUser(int id) async {
  final response = await http.get(
    Uri.parse('https://api.example.com/users/$id'),
    headers: {'Content-Type': 'application/json'},
  );

  if (response.statusCode == 200) {
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return User.fromJson(json);
  } else {
    throw Exception('Failed to load user: ${response.statusCode}');
  }
}

Future<List<Post>> fetchPosts() async {
  final response = await http.get(
    Uri.parse('https://api.example.com/posts'),
  );
  if (response.statusCode == 200) {
    final list = jsonDecode(response.body) as List;
    return list
        .map((item) => Post.fromJson(item as Map<String, dynamic>))
        .toList();
  }
  throw Exception('HTTP ${response.statusCode}');
}

Using Dio

import 'package:dio/dio.dart';

final _dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 10),
));

Future<User> fetchUser(int id) async {
  try {
    final response = await _dio.get<Map<String, dynamic>>('/users/$id');
    return User.fromJson(response.data!);
  } on DioException catch (e) {
    throw Exception('Network error: ${e.message}');
  }
}

// Dio interceptor for global error handling
_dio.interceptors.add(
  InterceptorsWrapper(
    onError: (error, handler) {
      // Log, refresh tokens, or redirect to login
      handler.next(error);
    },
  ),
);

State Management Integration

Generated Dart models integrate naturally with Riverpod and BLoC — the two most popular state management solutions for Flutter.

Riverpod with AsyncNotifier

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'user_provider.g.dart';

@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  Future<User> build(int userId) => _fetchUser(userId);

  Future<User> _fetchUser(int id) async {
    final response = await http.get(
      Uri.parse('https://api.example.com/users/$id'),
    );
    return User.fromJson(
      jsonDecode(response.body) as Map<String, dynamic>,
    );
  }

  Future<void> updateName(String newName) async {
    final current = await future;
    state = AsyncData(current.copyWith(name: newName));
  }
}

// Usage in a widget:
class UserCard extends ConsumerWidget {
  final int userId;
  const UserCard({required this.userId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userNotifierProvider(userId));
    return userAsync.when(
      data: (user) => Text(user.name),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('Error: $e'),
    );
  }
}

BLoC Pattern

// Event
abstract class UserEvent {}
class FetchUser extends UserEvent { final int id; FetchUser(this.id); }

// State (using freezed)
@freezed
sealed class UserState with _$UserState {
  const factory UserState.initial() = _Initial;
  const factory UserState.loading() = _Loading;
  const factory UserState.loaded(User user) = _Loaded;
  const factory UserState.error(String message) = _Error;
}

// BLoC
class UserBloc extends Bloc<UserEvent, UserState> {
  UserBloc() : super(const UserState.initial()) {
    on<FetchUser>(_onFetchUser);
  }

  Future<void> _onFetchUser(
    FetchUser event,
    Emitter<UserState> emit,
  ) async {
    emit(const UserState.loading());
    try {
      final response = await http.get(
        Uri.parse('https://api.example.com/users/${event.id}'),
      );
      final user = User.fromJson(
        jsonDecode(response.body) as Map<String, dynamic>,
      );
      emit(UserState.loaded(user));
    } catch (e) {
      emit(UserState.error(e.toString()));
    }
  }
}

Hive Local Storage

Hive is a fast, lightweight local database for Flutter. You can persist your JSON-derived models using HiveObject and TypeAdapters:

import 'package:hive/hive.dart';

part 'cached_user.g.dart';

@HiveType(typeId: 0)
class CachedUser extends HiveObject {
  @HiveField(0)
  final int id;

  @HiveField(1)
  final String name;

  @HiveField(2)
  final String? email;

  CachedUser({required this.id, required this.name, this.email});

  // Convert from network model to cache model
  factory CachedUser.fromUser(User user) =>
      CachedUser(id: user.id, name: user.name, email: user.email);

  // Convert back to network model
  User toUser() => User(id: id, name: name, email: email,
      createdAt: DateTime.now()); // restore from cache
}
// Initialize Hive
void main() async {
  await Hive.initFlutter();
  Hive.registerAdapter(CachedUserAdapter()); // generated
  await Hive.openBox<CachedUser>('users');
  runApp(const MyApp());
}

// Persist a model
Future<void> cacheUser(User user) async {
  final box = Hive.box<CachedUser>('users');
  await box.put(user.id, CachedUser.fromUser(user));
}

// Read from cache
CachedUser? getUser(int id) {
  return Hive.box<CachedUser>('users').get(id);
}

Testing JSON Models

A roundtrip test ensures that serialization and deserialization are inverses of each other. Write one for every model:

import 'package:test/test.dart';
import 'dart:convert';

void main() {
  group('User model', () {
    final sampleJson = {
      'id': 1,
      'name': 'Alice',
      'email': 'alice@example.com',
      'created_at': '2024-01-15T10:30:00.000Z',
    };

    test('fromJson creates correct User', () {
      final user = User.fromJson(sampleJson);
      expect(user.id, equals(1));
      expect(user.name, equals('Alice'));
      expect(user.email, equals('alice@example.com'));
      expect(user.createdAt.year, equals(2024));
    });

    test('toJson produces correct map', () {
      final user = User.fromJson(sampleJson);
      final json = user.toJson();
      expect(json['id'], equals(1));
      expect(json['name'], equals('Alice'));
    });

    test('roundtrip preserves all fields', () {
      final user = User.fromJson(sampleJson);
      final restored = User.fromJson(user.toJson());
      expect(restored.id, equals(user.id));
      expect(restored.name, equals(user.name));
      expect(restored.email, equals(user.email));
    });

    test('handles null email', () {
      final jsonWithoutEmail = {...sampleJson}..remove('email');
      final user = User.fromJson(jsonWithoutEmail);
      expect(user.email, isNull);
    });

    test('fromJson with raw JSON string', () {
      final jsonString = jsonEncode(sampleJson);
      final user = User.fromJson(
        jsonDecode(jsonString) as Map<String, dynamic>,
      );
      expect(user.id, equals(1));
    });
  });
}

Common Mistakes to Avoid

These are the most frequent errors Flutter developers encounter when working with JSON serialization:

1. Forgetting to run build_runner

After adding or modifying @JsonSerializable() classes, always run `flutter pub run build_runner build --delete-conflicting-outputs`. The .g.dart file is not updated automatically.

2. Using dynamic instead of typed casts

// Bad — silently passes wrong types at runtime
final name = json['name'];            // dynamic
final count = json['count'];          // dynamic, may be int or String
// Good — fails fast with a clear cast error
final name = json['name'] as String;
final count = json['count'] as int;

3. Treating missing keys as null

// Bad — throws if key is missing
final value = json['maybe_missing'] as String?; // still throws if key absent

// Good — safely handle absent keys
final value = json['maybe_missing'] as String?;  // ok if key present with null
// OR
final value = (json['maybe_missing'] ?? '') as String; // default value

4. Not handling List type erasure

// Bad — List<dynamic>, not List<String>
final tags = json['tags'] as List<String>; // ClassCastException at runtime
// Good — explicit cast
final tags = List<String>.from(json['tags'] as List);

5. Missing part directives

Both json_serializable and freezed require part directives. For json_serializable: `part 'filename.g.dart';`. For freezed: both `part 'filename.freezed.dart';` AND `part 'filename.g.dart';`.

Frequently Asked Questions

What is the difference between json_serializable and freezed?

json_serializable generates fromJson and toJson methods for mutable classes. freezed builds on top of json_serializable to create immutable classes with value equality (== and hashCode), copyWith(), and sealed union types. Use json_serializable for simple models and freezed when you need immutability or union types for state management.

Do I need to run build_runner every time I change a model?

Yes, but you can use watch mode to avoid manual runs: `flutter pub run build_runner watch --delete-conflicting-outputs`. This monitors your files and regenerates code automatically when you save. The --delete-conflicting-outputs flag handles stale generated files.

How do I handle JSON with snake_case keys and camelCase Dart fields?

With json_serializable, add @JsonSerializable(fieldRename: FieldRename.snake) to your class. This automatically maps snake_case JSON keys to camelCase Dart field names without needing individual @JsonKey(name:) annotations on every field.

How do I parse a JSON array at the top level?

When the API returns a JSON array (not an object) at the root level, decode it as a List and map each element: `final list = jsonDecode(response.body) as List; return list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();`

What happens if a required field is missing from the JSON?

If you cast with `json['field'] as String` and the key is absent, json['field'] returns null and the cast throws a type cast error at runtime. Always use null-safe casts (`as String?`) for fields that might be absent, or provide defaults with `?? defaultValue`.

Can I use json_serializable with Flutter Web?

Yes, json_serializable is platform-independent and works identically on Flutter Mobile, Web, and Desktop. The generated code uses only dart:core types.

How do I serialize a class that extends another class?

Use the explicitToJson: true option on @JsonSerializable and call super.toJson() manually, or restructure using composition instead of inheritance. json_serializable does not automatically include superclass fields, so explicitly add them in toJson() or use @JsonSerializable on the base class too.

Should I generate Dart models from every API response or only some?

Generate models for any JSON structure you access more than once, pass between functions, or store in state. For one-off API calls where you only read one or two fields, `jsonDecode` and direct map access is acceptable. As a project grows, typed models almost always pay off in maintainability.

Try our free JSON to Dart converter

JSON to Dart →
𝕏 Twitterin LinkedIn
Cet article vous a-t-il aidé ?

Restez informé

Recevez des astuces dev et les nouveaux outils chaque semaine.

Pas de spam. Désabonnez-vous à tout moment.

Essayez ces outils associés

DTJSON to Dart{ }JSON FormatterTSJSON to TypeScriptKTJSON to Kotlin

Articles connexes

JSON vers TypeScript en ligne : Le guide complet pour developpeurs

Apprenez a generer automatiquement des types TypeScript a partir de JSON. Interface vs type, champs optionnels/nullable, objets imbriques, types union, validation Zod, types API generiques et bonnes pratiques tsconfig.

JSON vers Kotlin Data Class : Guide kotlinx.serialization, Moshi et Gson

Convertir JSON en data class Kotlin en ligne. Apprenez le parsing JSON avec kotlinx.serialization, Moshi et Gson.