How To Write A Proper Dart Data Model (AI Prompt Included)

Flutter category image

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:

  1. Contains all required properties
    This should be obvious 🙂
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 to null.

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:

Dart
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:

Dart
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:

Dart
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:

Dart
// 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:

Dart
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:

Dart
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:

Dart
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:

Dart
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.

Bash
flutter pub add json_annotation dev:build_runner dev:json_serializable

Have a look at this code snippet below to see how it works:

Dart
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:

Dart
// 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:

Bash
flutter pub add freezed_annotation dev:build_runner dev:freezed

Then, write your class like this:

Dart
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:

Dart
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:

Plaintext
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.

Dart Quicktype web app to convert JSON into Dart code

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.


Want More Flutter Content?

Join my bi-weekly newsletter that delivers small Flutter portions right in your inbox. A title, an abstract, a link, and you decide if you want to dive in!

Flutter ❤️ Firebase

Get started with Firebase and learn how to use it in your Flutter apps. My detailed ebook delivers you everything you need to know! Flutter and Firebase are a perfect match!

Become A Testing Expert!

Become a proficient Flutter app tester with my detailed guide. This ebook covers everything from unit tests over widget tests up to dependency mocking.