Introduction
Here is how to write a proper Dart data model by hand, with various packages, and with AI assistance to make your life easier.
Writing good data models is essential for every developer. In this article I want to show you how to write a proper Dart data model manually, with package support, through AI assistance, or with a web tool. Take this as a starting point to write better models in the future.
What a proper data model should contain
A data model usually has the following capabilities:
- Contains all required properties
This should be obvious 🙂 - Is immutable
Immutable means you cannot change the object once it is created. Mutable object tend to produce more errors if you are not careful enough and can introduce bugs more easily. - Offers a copy option
This is a way to handle the immutability. Instead of changing a value in your original object, you create a copy of it with the changes. - Has a serialization/deserialization feature
When working with database or APIs, this is a must-have feature. JSON is the default data format of the internet but there are also other formats around like XML. - Handles equality comparison
To know if two instances are equal or not is vital for many apps. By implementing a proper equality comparison, you make sure that your app doesn’t just compare memory addresses. - Has additional constructors to create special objects like default ones
A common use case is to create an empty object with all properties set to their default values. You can use this it instead of setting a reference tonull
.
Which of those features you need, always depends on your situation. Maybe you don’t need additional constructors or a copy method. Or maybe you need something that isn’t listed here. Always model your class so that it satisfies your needs.
Now let’s see how to write a proper Dart data model manually, with packages, and with an AI prompt.
Build the model yourself
The more features your model needs the less likely it is that you are writing the entire code. Packages and AI have proven to save you a lot of time and trouble.
Anyway, here is how you would do it without any help.
Immutable properties
We start by defining immutable properties in our class. The keyword final
is helpful here. Then we create a constructor to inject the values.
Here is an example with only primitive data types:
class MyModel {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
MyModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
}
Copy option
Since the model is immutable, we cannot change the values. One solution is to create a copy with the changed values.
Here is how it could look like:
class MyModel {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
MyModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
MyModel copyWith(
String? newId,
double? newPrice,
DateTime? newCreatedAt,
bool? newEnabled,
) {
return MyModel(
id: newId ?? id,
price: newPrice ?? price,
createdAt: newCreatedAt ?? createdAt,
enabled: newEnabled ?? enabled,
);
}
}
The copyWith
method has all model properties as nullable arguments. If an argument is set, the value overwrites the current value in the new object.
You’ve probably already noticed that you need to adapt this method if you add or change a model property. This is one of the tasks that the packages and AI can take care of for you.
Serialization and deserialization
REST APIs or NoSQL databases usually work with JSON. To use them we need to convert our model in a JSON string (and reconvert it to a model when getting results back). That is what serialization is all about.
Here is an example for our model:
class MyModel {
// code parts omitted for brevity
factory MyModel.fromJson(Map<String, dynamic> json) {
return MyModel(
id: json['id'] as String,
price: (json['price'] as num).toDouble(),
createdAt: DateTime.parse(json['createdAt'] as String),
enabled: json['enabled'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'price': price,
'createdAt': createdAt.toIso8601String(),
'enabled': enabled,
};
}
}
There is a factory constructor that takes the decoded JSON response (from an API or database) and creates a model instance from it by parsing, converting, and mapping all JSON key-value pairs to model properties.
The toJson
method is straightforward. Create a map, pass property names as map keys and property values as map values. That’s it.
⚠️ Notice that the second type argument of the Map
object is dynamic
. This allows setting all kinds of data types but it can lead to runtime errors because the compiler cannot check for type errors.
Here are two more robust versions for the fromJson
constructor:
// option 1: throw exception when key is missing or data type is wrong
factory MyModel.fromJson(Map<String, dynamic> json) {
if (!json.containsKey('id') || json['id'] is! String) {
throw FormatException('Missing or invalid "id": Expected a non-null String.');
}
if (!json.containsKey('price') || json['price'] is! num) {
throw FormatException('Missing or invalid "price": Expected a non-null number.');
}
if (!json.containsKey('createdAt') || json['createdAt'] is! String) {
throw FormatException('Missing or invalid "createdAt": Expected a non-null String.');
}
if (!json.containsKey('enabled') || json['enabled'] is! bool) {
throw FormatException('Missing or invalid "enabled": Expected a boolean.');
}
final createdAt = DateTime.tryParse(json['createdAt'] as String);
if (createdAt == null) {
throw FormatException('Invalid DateTime format for "createdAt".');
}
return MyModel(
id: json['id'] as String,
price: (json['price'] as num).toDouble(),
createdAt: createdAt,
enabled: json['enabled'] as bool,
);
}
// option 2: set default values on error
factory MyModel.fromJson(Map<String, dynamic> json) {
return MyModel(
id: json['id'] is String ? json['id'] as String : '',
price: json['price'] is num ? (json['price'] as num).toDouble() : 0.0,
createdAt:
json['createdAt'] is String
? DateTime.tryParse(json['createdAt'] as String) ??
DateTime(1970, 1, 1)
: DateTime(1970, 1, 1),
enabled: json['enabled'] is bool ? json['enabled'] as bool : false,
);
}
Again, you can see that there is a lot of boilerplate code and the amount grows the more properties your model has.
Equality check
To make equality checks work, we need to overwrite the equality operator ==
and the hashCode
getter. Then, the compiler can decide if two instances are the same. Usually you would consider two instances the same if properties have the same values.
Here is some example code:
class MyModel {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
MyModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
// code parts omitted for brevity
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MyModel &&
other.id == id &&
other.price == price &&
other.createdAt == createdAt &&
other.enabled == enabled;
}
@override
int get hashCode =>
id.hashCode ^ price.hashCode ^ createdAt.hashCode ^ enabled.hashCode;
}
If you want to dive deeper into equality checks in Dart, check out this article:

Additional constructors
I often use additional constructors to create special objects like an empty one with all properties set to their default values. It helps me to not rely on null
values for example.
Have a look at this code:
class MyModel {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
MyModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
MyModel.empty()
: this(id: "", price: 0.0, createdAt: DateTime(1970, 1, 1), enabled: false);
}
I use a named constructor that redirects to the default constructor and sets default values. It’s a quick and handy way to create an empty object.
Summary
Now we have applied a lot of functionality to our model class. The final result looks something like this:
class MyModel {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
MyModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
MyModel.empty()
: this(id: "", price: 0.0, createdAt: DateTime(1970, 1, 1), enabled: false);
MyModel copyWith(
String? newId,
double? newPrice,
DateTime? newCreatedAt,
bool? newEnabled,
) {
return MyModel(
id: newId ?? id,
price: newPrice ?? price,
createdAt: newCreatedAt ?? createdAt,
enabled: newEnabled ?? enabled,
);
}
factory MyModel.fromJson(Map<String, dynamic> json) {
return MyModel(
id: json['id'] is String ? json['id'] as String : '',
price: json['price'] is num ? (json['price'] as num).toDouble() : 0.0,
createdAt:
json['createdAt'] is String
? DateTime.tryParse(json['createdAt'] as String) ??
DateTime(1970, 1, 1)
: DateTime(1970, 1, 1),
enabled: json['enabled'] is bool ? json['enabled'] as bool : false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'price': price,
'createdAt': createdAt.toIso8601String(),
'enabled': enabled,
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MyModel &&
other.id == id &&
other.price == price &&
other.createdAt == createdAt &&
other.enabled == enabled;
}
@override
int get hashCode =>
id.hashCode ^ price.hashCode ^ createdAt.hashCode ^ enabled.hashCode;
}
We have immutable properties ✅, a copy option ✅, a serialization/deserialization option ✅, equality comparison ✅, and additional constructors ✅. All of that in 66 lines of code!
But when we add a new property, we need to modify the class a lot and then the number grows quickly.
A better approach is to rely on packages. So let’s see how that works!
Use packages for model creation
The major benefit of packages is that you don’t need to write all the boilerplate code. And in addition, you don’t risk making a silly mistake. With packages, you are on the safe side because they have proven their correctness in the past.
As a downside packages introduce dependencies. If you have many of them, there could be conflicts even if you update them regularly. In general, it should not be an issue but be aware that it can happen!
equatable
The equatable package adds equality checks to your class. You don’t need to overwrite hashCode
and the ==
operator anymore.
Install the package, extend your class from Equatable
, and override the props
property. That’s it!
Everything in props
will be used to check for equality. You can also limit this to only the id
if it makes sense in your case.
Here is some code for you:
class MyEquatableModel extends Equatable {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
const MyEquatableModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
@override
List<Object?> get props => [id, price, createdAt, enabled];
}
equatable feature summary
- Immutable properties ❌
- Copy option ❌
- Serialization/deserialization ❌
- Equality checks ✅
- Additional constructors ❌
json_serializable
To add serialization and deserialization capabilities, try out json_serializable.
It generates robust serialization and deserialization methods with many configuration options using the build runner.
To use it, install json_serializable
and build_runner
in your project. In addition, you need json_annotation for more configuration options.
flutter pub add json_annotation dev:build_runner dev:json_serializable
Have a look at this code snippet below to see how it works:
import 'package:json_annotation/json_annotation.dart';
// where the generated code will be put
part 'my_serializable_model.g.dart';
@JsonSerializable() // required annotation
class MySerializableModel {
final String id;
final double price;
final DateTime createdAt;
final bool enabled;
const MySerializableModel({
required this.id,
required this.price,
required this.createdAt,
required this.enabled,
});
// connect to the generated deserialization method
factory MySerializableModel.fromJson(Map<String, dynamic> json) => _$MySerializableModelFromJson(json);
// connect to the generated serialization method
Map<String, dynamic> toJson() => _$MySerializableModelToJson(this);
}
Execute the build runner with dart run build_runner build
and wait. After that, your class has working fromJson
and toJson
methods.
Here is the generated code:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'my_serializable_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MySerializableModel _$MySerializableModelFromJson(Map<String, dynamic> json) =>
MySerializableModel(
id: json['id'] as String,
price: (json['price'] as num).toDouble(),
createdAt: DateTime.parse(json['createdAt'] as String),
enabled: json['enabled'] as bool,
);
Map<String, dynamic> _$MySerializableModelToJson(
MySerializableModel instance,
) => <String, dynamic>{
'id': instance.id,
'price': instance.price,
'createdAt': instance.createdAt.toIso8601String(),
'enabled': instance.enabled,
};
Your database name is probably not always identical to your property name. A good example is the createdAt
property. If your database uses created_at
you will get a runtime error. To fix this, add @JsonKey(name: "created_at")
above your property and rerun the build runner.
You can also have default values and checks included but this has some limitations. For example, you cannot set a default value for DateTime
properties (at least not without additional code to convert it).
While the package is quite mighty, I never really used it because of this important drawback. It doesn’t save you a lot of code that way.
For more configuration options check the package documentation!
json_serializable feature summary
- Immutable properties ❌
- Copy option ❌
- Serialization/deserialization ✅
- Equality checks ❌
- Additional constructors ❌
freezed
freezed is another package that helps reducing boilerplate code. You get immutable properties, a copy option, and equality check by default. To make it work, you need to install the following packages:
flutter pub add freezed_annotation dev:build_runner dev:freezed
Then, write your class like this:
import 'package:freezed_annotation/freezed_annotation.dart';
// where the generated code will be put
part 'my_freezed_model.freezed.dart';
@freezed // required annotation
abstract class MyFreezedModel with _$MyFreezedModel { // add mixin _$YourClass
const factory MyFreezedModel({
required String id,
required double price,
required DateTime createdAt,
required bool enabled,
}) = _MyFreezedModel; // add _YourClass
}
The annotation is a bit more complex but together with the build runner you get a perfectly usable class!
In addition, freezed
can work together with json_serializable
. Modify your class and add the required code:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_freezed_model.freezed.dart';
// additional part file
part 'my_freezed_model.g.dart';
@freezed
abstract class MyFreezedModel with _$MyFreezedModel {
const factory MyFreezedModel({
required String id,
required double price,
required DateTime createdAt,
required bool enabled,
}) = _MyFreezedModel;
// factory, creates fromJson AND toJson
factory MyFreezedModel.fromJson(Map<String, dynamic> json) => _$MyFreezedModelFromJson(json);
}
⚠️ Don’t forget to add the required packages for json_serializable!
The last thing that is missing on our list are additional constructors. You can add them manually to the class definition but there is no automated way to generate a special constructor for you.
The package documentation explains many more configuration options!
freezed feature summary
- Immutable properties ✅
- Copy option ✅
- Serialization/deserialization ❌
- Equality checks ✅
- Additional constructors ❌
Summary
You can reduce boilerplate code by a lot when using packages. The more features you need and the more properties you have, the more they make sense.
If packages are not your way to go, then maybe AI can help you. Let’s see how AI can show us how to write a proper Dart data model.
Let AI create the model for you
Since AI came up, more and more developers use it in their daily work. I also came to the conclusion that it can save you a lot of time.
Especially for type-intensive tasks, AI make a huge productivity difference!
Creating data models is such a task. Here is a prompt that returns pretty good results:
Help me write a Dart data class according to my requirements.
I will start by telling you how to create the class and in the end
I will give you the properties that the class should have.
General rules:
- Don't add any code comments
- Just print the code, no explanations or anything else
Requirements:
- The class must be immutable, all fields must be final.
- Create a named constructor .empty() that initializes all properties
with a default value.
- Create a copyWith method for the class. Use named parameters.
- Create a factory fromJson constructor for deserialization.
For every property check if the key exists in the JSON and
if the value has the correct data type. If not, set a default
value for primitive types. For nested types, assume there is a
named constructor .empty(). Use this as a default value.
- Create a toJson method.
- Assume the property names are identical to the JSON keys.
- Generate nested types according to the same rules.
- Override hashCode and the == operator to provide equality checks.
Assume instances are equal if all property values are equal.
MyAiModel Properties:
- String id
- double price
- DateTime createdAt
- bool enabled
- NestedObject data
NestedObject properties:
- List<String> tags
- bool active
Steal this prompt and adjust it to your needs. It will make your life a lot easier.
Another big benefit is that AIs can update your models easily. To do that just paste the model and tell the AI what properties you want to have added, removed, changed.
There are some free AI tools around that you can use. I tried Cody in VS Code. It is amazing and free to use. Check out my article below:

AI feature summary
- Immutable properties ✅
- Copy option ✅
- Serialization/deserialization ✅
- Equality checks ✅
- Additional constructors ✅
Use Dart Quicktype
Dart Quicktype is a web app that transforms JSON into Dart code. You can customize the output with various settings to get exactly the class you need.
It understands nested objects, can rely on json_serializable
for serialization, has support for equatable
, and creates a copyWith
method.

The downside is that you need to model your classes from JSON first. If you have an API result to convert, then it is a no-brainer.
I use this tool a lot and especially for long classes since AI still tends to make some smaller mistakes.
Dart Quicktype feature summary
- Immutable properties ✅
- Copy option ✅
- Serialization/deserialization ✅
- Equality checks ✅
- Additional constructors ❌
Conclusion
In this article we saw different ways how to write a proper Dart data model manually, with package support, with AI assistance, or by using online tools. At some point, the manual way just doesn’t make sense anymore and you might want to try out alternatives.
Related articles

