DEV Community

loading...

Data Modeling with Flutter using freezed package

Carlo Miguel Dy
Software Engineer
・16 min read

Introduction

In this post we'll cover how you can create a data model using freezed package in Flutter and learn about a few techniques that I know and have been using when building projects. We will be using JSONPlacheolder to consume a REST API with dio as our HTTP client and create a data model for the /users endpoint to the Flutter application.

Data modeling is a process of creating a visual representation of information that describes the a business entity of a software project. It is a technique that is being often used in most applications. A data model can also represent relationship between each business entities. Or if you know about relational databases then for a quick comparison, a data model can be an SQL table that represents it.

You can read more in depth about data modeling here

Installation

First we will begin with installing a fresh Flutter project, so go over and open up your terminal and execute the following command below.

flutter create freezed_data_modeling
Enter fullscreen mode Exit fullscreen mode

Then open the project in your IDE (Visual Studio Code) and open up the pubspec.yaml file and add up the following dependencies that we require,

  • dependencies:
    • freezed_annotation
  • dev_dependencies:
    • build_runner
    • freezed
dependencies:
  flutter:
    sdk: flutter
  freezed_annotation:
    dio:

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner:
  freezed:
Enter fullscreen mode Exit fullscreen mode

And finally execute flutter pub get when you've added those dependencies.

Next you can launch an emulator and run the project in it, but you can do it later.

JSONPlaceholder response

Before we start creating our data models, we should know what resources are getting returned to the application. It wouldn't make sense to create data models when the data model you created does not reflect in the backend. Take a look at it carefully and identify which properties are of type String , double , int , and etc.

// https://jsonplaceholder.typicode.com/users
[
  {
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "city": "Gwenborough",
      "zipcode": "92998-3874",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
      "name": "Romaguera-Crona",
      "catchPhrase": "Multi-layered client-server neural-net",
      "bs": "harness real-time e-markets"
    }
  },

  ...
]
Enter fullscreen mode Exit fullscreen mode

When taking a closer look at it we can identify the following,

  • id is of type int because it returns a single digit
  • name contains various characters, so this is a String
  • username is a String
  • email is a String
  • address is a JSON object, so we'll require to create its own data model as well because we don't wanna put up a type for that as Map<String, dynamic> that would make less robust

    So let's identify the property types for each property in address field:

    • street is a String
    • suite is a String
    • city is a String
    • zipcode is a String
    • geo is another JSON object, so we'll do the same thing as what we are currently doing for address
      • lat can be a String but since we know it is "latitude" and usually they are in decimal values, so we'll use double for that instead
      • lng is double
  • phone is a String

  • website is a String

  • company is a JSON object

    • name is a String
    • catchPhrase is a String
    • bs is a String

Creating a data model without freezed

Usually when we start to create our data models is that we use this approach, also means that we are writing out a lot of "boilerplate" code which isn't very ideal when you have a large complex application. One could be spending a lot of time just writing up data models this way, manually adding method calls like toJson() to convert the data model into a JSON format which is the typical format we use when we are creating requests to a REST API, GraphQL API or any sort of backend service that you could be using.

class Geo {
  final double lat;
  final double lng;

  Geo({
    this.lat = 0.0,
    this.lng = 0.0,
  });

  Map<String, dynamic> toJson() {
    return {
      'lat': lat,
      'lng': lng,
    };
  }
}

class Address {
  final String? street;
  final String? suite;
  final String? city;
  final String? zipcode;

  Address({
    this.street,
    this.suite,
    this.city,
    this.zipcode,
  });

  Map<String, dynamic> toJson() {
    return {
      'street': street,
      'suite': suite,
      'city': city,
      'zipcode': zipcode,
    };
  }
}

class Company {
  final String? name;
  final String? catchPhrase;
  final String? bs;

  Company({
    this.name,
    this.catchPhrase,
    this.bs,
  });

  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'catchPhrase': catchPhrase,
      'bs': bs,
    };
  }
}

class User {
  final int id;
  final String? username;
  final String? email;
  final Address? address;
  final String? phone;
  final String? website;
  final Company? company;

  User({
    required this.id,
    this.username,
    this.email,
    this.address,
    this.phone,
    this.website,
    this.company,
  });

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'username': username,
      'email': email,
      'address': address?.toJson(),
      'phone': phone,
      'website': website,
      'company': company?.toJson(),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

This would take up a lot of your time and could even be counterproductive for you as a developer.

Also notice how I am putting up with the ? annotation in each of the properties since they could potentially be null and that would be considered a bug and will produce crashes in the application during run time or if when our users are using our application.

You can learn more about null-safety here

There are quite a lot of problems that this gives us when writing our applications since there comes a point where when we will have to copy all values and replace with a new value. In JavaScript we can simply do that with the "triple dot" notation to create a new object from an existing object.

const person = {
  name: "Carlo Miguel Dy",
  age: 23,
}

console.log(person)
// { "name": "Carlo Miguel Dy", "age": 23 }

const newPerson = {
  ...person,
  name: "John Doe",
}

console.log(newPerson)
// { "name": "John Doe", "age": 23 }
Enter fullscreen mode Exit fullscreen mode

But unfortunately we can't do that with Dart yet. We can do that but it's quite a lot of "boilerplate" code.

Creating a data model with freezed

A lot of the "boilerplate" code is eliminated when we are using the freezed package, what it does is it generates all those "boilerplate" code for us, we can also make use of its annotations which comes very handy. To mention a few these are @Default for providing a default value when this property is null and we also use @JsonKey to override the JSON key just in case the conventions is different from the backend. Some do use camelCasing , snake_casing and PascalCasing so these are the kind of problem it can solve with only a few lines of code.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'freezed_datamodels.freezed.dart';
part 'freezed_datamodels.g.dart';

@freezed
class Geo with _$Geo {
  const factory Geo({
    @Default(0.0) double lat,
    @Default(0.0) double lng,
  }) = _Geo;

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

@freezed
class Address with _$Address {
  const factory Address({
    @Default('') String street,
    @Default('') String suite,
    @Default('') String city,
    @Default('') String zipcode,
    Geo? geo,
  }) = _Address;

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

@freezed
class Company with _$Company {
  const factory Company({
    @Default('') String name,
    @Default('') String catchPhrase,
    @Default('') String bs,
  }) = _Company;

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

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String username,
    required String email,
    Address? address,
    @Default('') String phone,
    @Default('') String website,
    Company? company,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
} 
Enter fullscreen mode Exit fullscreen mode

We only have to write a few lines of code to create the data models and we are utilizing on the annotations that the freezed package provides for us.

To break down each data model I defined above for you,

  • Geo
    • The lat property is non-nullable but has a default value of 0.0 when this value is empty
    • The lng property is non-nullable but has a default value of 0.0 when this value is empty
  • Address
    • The street property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The suite property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The city property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The zipcode property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
  • Company
    • The name property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The catchPhrase property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The bs property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
  • User
    • The id property is non-nullable but is required and this property should never be null
    • The username property is non-nullable but is required and this property should never be null
    • The email property is non-nullable but is required and this property should never be null
    • The address property is nullable
    • The phone property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The website property is non-nullable but has a default value of '' which means it will be an empty string when this value is empty
    • The company property is nullable

Generating code using build_runner package

As the code snippet above you will notice that we have 2 lines of code that uses part and it contains freezed and g these are the ones that will let freezed package to recognize that it requires to generate a code whenever we execute the build_runner to build generate code for us.

To start generating code for us, execute the following command with-in the root directory of your Flutter project.

flutter pub run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

To break it down what each does, the flutter pub run will allow us to run script coming from a package like build_runner and the build is the command or script to tell the build_runner package to start generating code for us. It will look for files that contains the following *.freezed.dart and *.g.dart the * is just a wild card that means any file name that contains it will be recognized by the build_runner and lastly the --delete-conflicting-outputs flag will tell the build_runner package to delete any existing *.freezed.dart and *.g.dart files to prevent duplicate outputs or that it could potentially conflict with those.

So every time you might have to update your data model with new properties, you will always have to execute this the command snippet above to tell build_runner to generate code for us.

You can take a peek at what code was generated from the repository or directly from the links below:

Copying values from a data model but only change values of specific properties

Coming back with copying values of an object in JavaScript, we can now simply do that as well with our data model that is using the freezed package.

final person = User(
  id: 1,
  username: 'carlomigueldy',
  email: 'carlomigueldy@gmail.com',
);

person.toJson();
// { "id": 1, "username": "carlomigueldy", "email": "carlomigueldy@gmail.com", ... }

final newPerson = person.copyWith(
  username: 'johndoe123',
);

newPerson.toJson();
// { "id": 1, "username": "johndoe123", "email": "carlomigueldy@gmail.com", ... }
Enter fullscreen mode Exit fullscreen mode

That's really powerful.

Consuming the REST API

We just got a basic application installed, so remove all those comments that make the code too long in main.dart file.

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Create a final field with type Dio and instantiate it with a new Dio instance, then create a function called fetchUsers that will fetch data from this endpoint https://jsonplaceholder.typicode.com/users and set it in a private property with type List<User> called as _users , we can call this function when you will click on the FloatingActionButton or for call this function inside the initState lifecycle hook of a StatefulWidget , for convenience I will just have the code here below for you to reference on if you prefer to write it out yourself. But the full source code will be found in the repository for a better reference.

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final Dio dio = Dio();
  List<User> _users = [];

  Future<void> fetchUsers() async {
    final response = await dio.get(
      'https://jsonplaceholder.typicode.com/users',
    );
    print(response.data);
    final List list = response.data;

    setState(() {
      _users = list.map((e) => User.fromJson(e)).toList();
    });
  }

  @override
  void initState() {
    fetchUsers();

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView.builder(
        itemCount: _users.length,
        itemBuilder: (context, index) {
          final user = _users[index];

          return ListTile(
            title: Text(user.username),
            subtitle: Text(user.email),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: fetchUsers,
        tooltip: 'Fetch Users',
        child: Icon(Icons.data_usage),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

When you have this correctly in your code then it should look very similar to this screenshot.

image

Using "copyWith" method from a data model

Now let's make it a bit interesting, this is not a very practical approach when building out your applications but it will give you an idea and on how you can make use of this. There are a lot of use cases that you might require to use copWith method from a freezed data model.

For the sake of demonstration purposes, we will implement the following for when a user taps on any of the ListTile under the ListView we will change the value of the username property and append a string with value of " CLICKED", so for instance when "Bret" is tapped then we will have it display "Bret CLICKED". To put things into action, let's create a function that will take an argument as the index of that item of a ListTile then attach it on to the onTap property of a ListTile and just pass in the current index of that item.

void appendUsername(int index) {
    setState(() {
      _users[index] = _users[index].copyWith(
        username: '${_users[index].username} CLICKED',
      );
    });
  }

// ... 

body: ListView.builder(
        itemCount: _users.length,
        itemBuilder: (context, index) {
          final user = _users[index];

          return ListTile(
            title: Text(user.username),
            subtitle: Text(user.email),
            onTap: () => appendUsername(index),
          );
        },
      ),
Enter fullscreen mode Exit fullscreen mode

Then we'll have the following output when any of the ListTile is tapped.

image

Example usage for @JsonKey annotation

There are certain cases that we can make use of @JsonKey annotation when creating our data models, say we have this data model and we have a property named as catchPhrase when this gets generated from the build_runner package, the JSON property will be equivalent to catchPhrase so basically it will result to something like this json['catchPhrase']

In the current API we will be consuming, there'd be no problem since it uses camelCasing convention so we can just have that as is

But what happens if the API will have different set of conventions, and we don't do something about it, so eventually our property catchPhrase in our Company data model will always be null since it will never match CatchPhrase or catch_phrase that is returned in the application when it is trying to retrieve its value by json['catchPhrase']

@freezed
class Company with _$Company {
  const factory Company({
    @Default('') String name,
    @Default('') String catchPhrase,
    @Default('') String bs,
  }) = _Company;

  factory Company.fromJson(Map<String, dynamic> json) =>
      _$CompanyFromJson(json);
}
Enter fullscreen mode Exit fullscreen mode

So to fix that we will make use of the @JsonKey annotation, just add it before the @Default annotation in this case, then we can specify the JSON object property name like so

@freezed
class Company with _$Company {
  const factory Company({
    @Default('') String name,
    @JsonKey(name: 'catch_phrase') @Default('') String catchPhrase,
    @Default('') String bs,
  }) = _Company;

  factory Company.fromJson(Map<String, dynamic> json) =>
      _$CompanyFromJson(json);
}
Enter fullscreen mode Exit fullscreen mode

Then whenever we call Company.fromJson(json) it will parse that coming from a JSON format into the actual Company data model that we defined into our application. Instead of having it retrieve by json['catchPhrase'] we now retrieving it by what we defined in the name property of the @JsonKey annotation so in this case it will be json['catch_phrase']

I hope that makes sense.

Another way of how we can make use of it is when we require to convert a property into a JSON format. For example, for our User data model it contains a property for Company data model and an Address data model. So when we print it out we will have the following value.

// Where `user` is of type `User`

print(user.toJson());
// {id: 4, username: Karianne, email: Julianne.OConner@kory.org, address: Address(street: Hoeger Mall, suite: Apt. 692, city: South Elvis, zipcode: 53919-4257), phone: 493-170-9623 x156, website: kale.biz, company: Company(name: Robel-Corkery, catchPhrase: Multi-tiered zero tolerance productivity, bs: transition cutting-edge web services)}
Enter fullscreen mode Exit fullscreen mode

You will notice that the address property is not in its JSON format, it has the following value instead which tells us it is a class with the its corresponding property and values. Which isn't very ideal whenever we will have to pass this information back into the API, it will throw an exception instead. But this doesn't happen to often when you have to pass back a huge payload to the API.

address: Address(street: Hoeger Mall, suite: Apt. 692, city: South Elvis, zipcode: 53919-4257)
Enter fullscreen mode Exit fullscreen mode

The way we can fix that up is by using the property fromJson and toJson converters from the @JsonKey annotation. We declare it again before the @Default annotation when there exists, otherwise it's just the same spot before the actual property.

@freezed
class User with _$User {
  const User._();
  const factory User({
    required int id,
    required String username,
    required String email,
    @JsonKey(
      fromJson: User._addressFromJson,
      toJson: User._addressToJson,
    )
        Address? address,
    @Default('')
        String phone,
    @Default('')
        String website,
    @JsonKey(
      fromJson: User._companyFromJson,
      toJson: User._companyToJson,
    )
        Company? company,
  }) = _User;

  static Address? _addressFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Address.fromJson(json);
  }

  static Map<String, dynamic>? _addressToJson(Address? address) {
    if (address == null) return null;

    return address.toJson();
  }

  static Company? _companyFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Company.fromJson(json);
  }

  static Map<String, dynamic>? _companyToJson(Company? company) {
    if (company == null) return null;

    return company.toJson();
  }

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Enter fullscreen mode Exit fullscreen mode

To break it down for you,

The address we used the @JsonKey annotation and passed down the properties for fromJson with what we defined as a static member of the class which returns the same type of what the property address has (Address?) which is of type Address but is nullable. As per freezed package instructions on accessing the same member of the class, it is required that we will have to call this const User._();

@freezed
class User with _$User {
  const User._();

  const factory User({
      // ...
    @JsonKey(
      fromJson: User._addressFromJson,
      toJson: User._addressToJson,
    )
        Address? address,
        // ...
  }) = _User;

  static Address? _addressFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Address.fromJson(json);
  }

  static Map<String, dynamic>? _addressToJson(Address? address) {
    if (address == null) return null;

    return address.toJson();
  }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

The private static method _addressFromJson takes a first argument of type Map<String, dynamic> which represents a JSON value and is nullable, then in this method we check if the json argument is null and if that evaluates to true then we'll just return null otherwise we can call Address.fromJson(json) and have it return an instance of a data model Address that we defined.

static Address? _addressFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Address.fromJson(json);
  }
Enter fullscreen mode Exit fullscreen mode

The private static method _addressToJson takes a first argument of type Address which the data model that we defined is nullable, then in this method we check if the address argument is null and if that evaluates to true then we'll just return null otherwise we can call address.toJson() and have it return a JSON representation of it.

static Map<String, dynamic>? _addressToJson(Address? address) {
    if (address == null) return null;

    return address.toJson();
  }
Enter fullscreen mode Exit fullscreen mode

Finalizing our data models

import 'package:freezed_annotation/freezed_annotation.dart';

part 'freezed_datamodels.freezed.dart';
part 'freezed_datamodels.g.dart';

@freezed
class Geo with _$Geo {
  const factory Geo({
    @Default(0.0) double lat,
    @Default(0.0) double lng,
  }) = _Geo;

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

@freezed
class Address with _$Address {
  const Address._();
  const factory Address({
    @Default('')
        String street,
    @Default('')
        String suite,
    @Default('')
        String city,
    @Default('')
        String zipcode,
    @JsonKey(
      fromJson: Address._geoFromJson,
      toJson: Address._geoToJson,
    )
        Geo? geo,
  }) = _Address;

  static Geo? _geoFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Geo.fromJson(json);
  }

  static Map<String, dynamic>? _geoToJson(Geo? geo) {
    if (geo == null) return null;

    return geo.toJson();
  }

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

@freezed
class Company with _$Company {
  const factory Company({
    @Default('') String name,
    @Default('') String catchPhrase,
    @Default('') String bs,
  }) = _Company;

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

@freezed
class User with _$User {
  const User._();
  const factory User({
    required int id,
    required String username,
    required String email,
    @JsonKey(
      fromJson: User._addressFromJson,
      toJson: User._addressToJson,
    )
        Address? address,
    @Default('')
        String phone,
    @Default('')
        String website,
    @JsonKey(
      fromJson: User._companyFromJson,
      toJson: User._companyToJson,
    )
        Company? company,
  }) = _User;

  static Address? _addressFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Address.fromJson(json);
  }

  static Map<String, dynamic>? _addressToJson(Address? address) {
    if (address == null) return null;

    return address.toJson();
  }

  static Company? _companyFromJson(Map<String, dynamic>? json) {
    if (json == null) return null;

    return Company.fromJson(json);
  }

  static Map<String, dynamic>? _companyToJson(Company? company) {
    if (company == null) return null;

    return company.toJson();
  }

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Enter fullscreen mode Exit fullscreen mode

Then as changes were made, we can then generate a new code so let's tell build_runner to do that for us by executing it again in your terminal, run the following command

flutter pub run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

Conclusion

Cheers you have made it to this very last part! 🎉 Hope you enjoyed and learned something from this, should help you out when you strive to write clean code with-in your codebase.

We learned how we can create data models using the freezed package, we learned how we can make use of @JsonKey and creating a JSON converter for a specific field, we learned how we can use copyWith method to copy existing values and only replace the values that are specified in the parameters, and we learned how we can deal with any backend that returns different naming conventions for their JSON properties (camelCasing, snake_casing and PascalCasing). We may have only learned the basic of it and we can of course refactor some of it, but maybe we can tackle that next time, for now we are just going to make it work and that solves our problem.

If you liked this and find it useful, don't forget to show some love now hit up the like button! 💪 See you on the next one.

💻 Full source code can be found in here

Discussion (2)

Collapse
undecided profile image
Arnas

Tips! Json to Dart Model. Extension for VSCode can do exactly same thing with few clicks :)

Collapse
carlomigueldy profile image
Carlo Miguel Dy Author

Awesome! Thanks for sharing this, didn't know there's an extension for that :D