Carlo Miguel Dy
Posted on June 5, 2021
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
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:
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"
}
},
...
]
When taking a closer look at it we can identify the following,
-
id
is of typeint
because it returns a single digit -
name
contains various characters, so this is aString
-
username
is aString
-
email
is aString
-
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 asMap<String, dynamic>
that would make less robustSo let's identify the property types for each property in
address
field:-
street
is aString
-
suite
is aString
-
city
is aString
-
zipcode
is aString
-
geo
is another JSON object, so we'll do the same thing as what we are currently doing foraddress
-
lat
can be aString
but since we know it is "latitude" and usually they are in decimal values, so we'll usedouble
for that instead -
lng
isdouble
-
-
phone
is aString
website
is aString
-
company
is a JSON object-
name
is aString
-
catchPhrase
is aString
-
bs
is aString
-
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(),
};
}
}
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 }
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);
}
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 of0.0
when this value is empty - The
lng
property is non-nullable but has a default value of0.0
when this value is empty
- The
-
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
- The
-
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
- The
-
User
- The
id
property is non-nullable but isrequired
and this property should never benull
- The
username
property is non-nullable but isrequired
and this property should never benull
- The
email
property is non-nullable but isrequired
and this property should never benull
- 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
- The
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
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", ... }
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),
),
);
}
}
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),
),
);
}
}
When you have this correctly in your code then it should look very similar to this screenshot.
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),
);
},
),
Then we'll have the following output when any of the ListTile
is tapped.
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);
}
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);
}
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)}
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)
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);
}
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();
}
// ...
}
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);
}
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();
}
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);
}
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
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.
Posted on June 5, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.