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.
- 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.
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:
finalfields make the class effectively immutable after construction.requiredin 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 absentBest 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.0Step 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-outputsAfter 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.1import '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 value4. 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 β