Classes in Dart embody related pieces of logic into a named package. A class called Person can contain all of the logic related to a person in your code; functionally such as running, walking, thinking, or even properties such as eye color, height, weight and more. In this post we will look at a lot of examples together and go through various aspects of classes in Dart.

Anatomy of a Class

Every class definition in Dart starts with the keyword of class, and is followed by the name of the class in PascalCase, followed by opening and closing curly brackets as shown here:

// a simple class representing a Person
class Person {
  // your code will later go here
}

You cannot define two classes with the same name in the same scope! That way Dart won’t know which definition to pick if you try to instance Person. We haven’t talked about instantiation yet so don’t worry about that if you don’t know what that means. This means that the following code is invalid:

// a simple class representing a Person
class Person {
  // your code will later go here
}

// compile error 🚨
// duplicate class names found
class Person {
  // Dart won't compile this code sicne we already
  // have a class named Person
}

For this to work the two Person classes have to be defined in two different scopes. Scopes in Dart are outside the scope (I didn’t plan for this to line up so beautifully) of this article so we won’t discuss them now!

Instantiation

Instantiation is the process of creating an instance of a class. While a class embodies the logic that you plan to use in your code, every instance is a copy of that class and will carry with it, its own data. Imagine classes being the original document and instances copies of the original.

In Dart, you create an instance of a class by calling its constructor. An empty class like our Person class has a default constructor that Dart provides us with so we don’t have to write that. You can call the constructor by using parenthesis. Here is an example:

// instantiate a Person object
void testIt() {
  // This calls the empty constructor
  final foo = Person();
  foo.log();
}

Here we are creating an instance of the Person class using its default constructor. A default constructor is a special type of function on every class that you don’t have to create; Dart gives you a free constructor. Right now I assume that we don’t know anything about constructors so just assume that writing Person() creates a new copy of the Person class for you in memory with its own memory bucket. That means if you change anything in this copy, it won’t affect another instance’s data (usually)!

Constructors

Constructors are special functions whose names are equal to the name of the class they are implemented on. Their sole purpose is to set up all values required for instantiation of the class. For instance, if you are to order a car from a car manufacturer, you have to provide them with info about your preferences, the seat colors, the engine size, whether you want to drive with a stick or an automatic, etc. These are parameters that you provide to the manufacturer to then get an instance of the car. This process is then called instantiation of the Car class. When you purchase a car from a manufacturer, you get an instance or a copy of the car, you don’t buy the car. After you are done with your purchase, someone else can buy the exact same car with the exact same specs. The manufacturer won’t run out of that car just because you bought an instance / copy of that car!

By default, Dart provides an empty constructor for your classes but you can override this behavior. Let’s go back to the Person example:

class Person {
  // empty for now
}

void testIt() {
  final john = Person();
  // 🚨 this won't compile
  // attempting to create a constant instance
  // of the Person class results in a compile-time
  // error since the default constructor created by
  // Dart for all classes is NOT a constant constructor.
  const jane = Person();
}

Since the default constructor for classes is not a constant constructor, you will have a good reason to create your first constructor and make it a constant constructor too. Let’s see how:

class Person {
  // a constant constructor
  const Person();
}

void testIt() {
  // now we can create a constant instance
  // of our Person class using its const constructor
  const jane = Person();
}

The job of a constructor, as we read about earlier, is to prepare an instance (remember the car example) with all parameters that the instance might require before it is brought to life! Let’s see an example by extending our Person class to have some parameters such as first name and last name:

class Person {
  // a constant constructor that takes in the
  // first name and last name of the person
  // but doesn't really store them anywhere
  const Person(String firstName, String lastName);
}

void testIt() {
  // we then pass in the first name and last name
  // into the constructor of the Person class
  const jane = Person('Jane', 'Doe');
}

Here we are passing the first and the last name of the person to the default constant constructor of the Person class but the class is not really doing anything with these values. Usually, your class would store these values into what we call instance variables which you will learn about in the coming sections but for now, let’s try to do something with these values. We will simply print them out to the debug console:

class Person {
  // ⚠️ adding log statements to the constructor
  // prevents it from being a constant constructor!
  Person(String firstName, String lastName) {
    firstName.log();
    lastName.log();
  }
}

void testIt() {
  // 🚨 This won't compile anymore, since the default
  // constructor for the Person class is not a constant
  // anymore
  const jane = Person('Jane', 'Doe');
}

Now that we have added some logs to the constructor, it cannot be a constant constructor anymore and this is one of the things you will see yourself as you work more and more with Dart. Constant constructors are quite limited in their jurisdiction and what they are and are not allowed to do. Usually const constructors need to simply instantiate a constant instance of the class and that’s all they have to do. They won’t be able to do much more than that. If you want to have actual logic in your constructors, then you won’t be able to have them as const!

Instance Variables

When you buy your car with white seats, and I buy the same exact make and model but with black seats, we won’t affect each other’s cars. I get black seats and you get white. The color of the seats here is the instance variable of each of our cars’ instances. Let’s create a Car class with an instance variable that dictates which color the car is:

// a car class with an instance variable
// called seatColor of type String whose default
// value is "white"
class Car {
  var seatColor = 'white';
}

void testIt() {
  // create a new car and leaves the "seatColor"
  // instance variable as "white"
  final yourCar = Car();
  // creates another instance of the Car class
  // but changes the seatColor value to "black"
  // before assigning it to the "myCar" variable
  final myCar = Car()..seatColor = 'black';
}

Our Car class has an instance variable named seatColor of type String whose value can be modified at compile-time, meaning that after instantiation of Car, a car with black seats can all of a sudden change its seat color to become white, and that’s not quite a good design:

class Car {
  // the fact that the "seatColor" variable
  // is an actual modifiable variable (mutable)
  // makes for a bad class design
  var seatColor = 'white';
}

void testIt() {
  final myCar = Car()..seatColor = 'black';
  myCar.seatColor = 'white'; // not a good idea
}

What would be better is to combine what we’ve learnt so far about constructors and instance variables and create a Car class whose seat color cannot be changed after instantiation. This is simply done by changing our var keyword to final and creating a const constructor as we saw earlier:

class Car {
  // the seat color cannot be changed after
  // the Car instance is created and that's
  // actually a good idea!
  final String seatColor;
  // the this.seatColor is a handy shortcut for
  // accepting a value of the same type as the
  // "seatColor" variable and assigning it to the
  // seatColor instance variable
  const Car(this.seatColor);
}

void testIt() {
  const myCar = Car('white'); // this is fine
  myCar.seatColor = 'black'; // this won't compile (as expected)
}

Now let’s add a few more properties to our Car class all of which indicate some sort of a color and see how that pans out:

class Car {
  // 3 different string properties
  // all of which indicate some sort
  // of a color
  final String seatColor;
  final String bodyColor;
  final String rimColor;
  // at the site of defining the Car class
  // these values make sense since they are
  // clearly named
  const Car(this.seatColor, this.bodyColor, this.rimColor);
}

void testIt() {
  // this is confusing to the call-site
  // which color is which?
  const myCar = Car('red', 'blue', 'black');
}

Inside the testIt() function, passing red, blue and black is quite confusing at that point since we don’t know which instance variable each of these values is getting assigned to so we need to name those instance variables in the constructor. These kind of parameters are called required named parameters. Let’s look how they look like:

class Car {
  final String seatColor;
  final String bodyColor;
  final String rimColor;
  // required named parameters so that the 
  // call-site (in the testIt() function) can not only
  // pass the values for each parameter, but also the
  // names of the parameters.
  const Car({
    required this.seatColor,
    required this.bodyColor,
    required this.rimColor,
  });
}

void testIt() {
  // much better with named parameters
  const myCar = Car(
    seatColor: 'red',
    bodyColor: 'blue',
    rimColor: 'black',
  );
}

Instance variables are simply properties of an instance of a class, like the car-seat-color which we have been discussing. If the class is designed properly, changing that instance’s seat color should in no way affect any other instance of the Car class.

Initializer List

Initializer lists are extra pieces of logic placed inside constructors to (usually) calculate the value for additional instance variables. Let’s see an example:

class Person {
  final String firstName;
  final String lastName;
  final String fullName;
  // using an initializer list to initialize the
  // fullName instance variable using the
  // firstName and lastName values passed
  // to the constructor
  const Person(this.firstName, this.lastName)
      : fullName = '$firstName $lastName';
}

void testIt() {
  const johnDoe = Person('John', 'Doe');
  johnDoe.fullName.log(); // prints "John Doe"
}

Apart from this kind of logic we just saw, you can also make assertions inside your initializer lists. For instance, you could assert that neither the first nor the last name could be 1 letter long using the assert() function as shown here:

class Person {
  final String firstName;
  final String lastName;
  // making assertions inside the intializer list
  const Person(this.firstName, this.lastName)
      : assert(firstName.length > 1),
        assert(lastName.length > 1);
}

void testIt() {
  // will compile just fine
  const johnDoe = Person('John', 'Doe');
  johnDoe.log();
  // 🚨 will not compile
  const maryJane = Person('M', 'J');
}

Class Variables

Class variables are storage spaces that don’t change when new instances of the same class are created. For instance, a car manufactured to have 5 seats will have 5 seats for all owners of that car. If you and I purchase the same model and make, we will both get 5 seats. So a naive implementation of such a car would look like this:

// N number of instances of this
// class will get N separate instance
// variables named "seatCount" each of which
// holding the exact same value of 5 in memory
// this is usually not a good design
class Car {
  final seatCount = 5;
  const Car();
}

void testIt() {
  const myCar = Car();
  myCar.seatCount.log(); // 5
  const yourCar = Car();
  yourCar.seatCount.log(); // 5
}

The way to fix this design is to turn seatCount into a class variable:

class Car {
  // "seatCount" now is stored at the
  // Car level. Now the manufacturer is the only
  // one keeping track of the seat count, not
  // individual instances of the Car class.
  static const seatCount = 5;
  const Car();
}

void testIt() {
  // seatCount is now a class variable
  Car.seatCount.log(); // this is fine
  const myCar = Car();
  // seatCount is no longer available
  // at instance level
  myCar.seatCount.log(); // 🚨 compile time error
}

Getters and Setters

Every instance variable that you define on a class has hidden getters and setters which Dart writes for you, so you don’t have to manage them manually. Getters and setters are functions that manage a particular variable. Let’s go back to our Person example again:

class Person {
  String firstName;
  String lastName;
  String fullName;
  // a person class that has a fullName computed
  // property whose result is calculated based
  // on the value of the first and the last names
  Person(
    this.firstName,
    this.lastName,
  ) : fullName = '$firstName $lastName';
}

void testIt() {
  final janeDoe = Person('Jane', 'Doe');
  janeDoe.fullName.log(); // prints "Jane Doe"
}

This all looks great but what if we change the fullName property? That will not affect the first and the last name properties unfortunately:

final janeDoe = Person('Jane', 'Doe');
janeDoe.fullName.log(); // prints "Jane Doe"
// ⚠️ this won't change the first and last names
janeDoe.fullName = 'Alex Smith';
janeDoe.fullName.log(); // prints "Alex Smith"
janeDoe.firstName.log(); // 🚨 prints "Jane" instead of "Alex"
janeDoe.lastName.log(); // 🚨 prints "Doe" instead of "Smith"

In this particular example we want to make sure that changing the value of fullName also affects the values of first and last names. So it’s best to write a setter for fullName. A setter method is a special method that gets called when the property’s value is being set to a new value. Here is the simplest implementation of such a setter for fullName:

class Person {
  String firstName;
  String lastName;
  Person(this.firstName, this.lastName);

  String get fullName => '$firstName $lastName';

  set fullName(String newValue) {
    final split = newValue.split(' ');
    firstName = split[0];
    lastName = split[1];
  }
}

void testIt() {
  final janeDoe = Person('Jane', 'Doe');
  janeDoe.fullName.log(); // prints "Jane Doe"
  janeDoe.fullName = 'Alex Smith';
  janeDoe.fullName.log(); // prints "Alex Smith"
  janeDoe.firstName.log(); // prints "Alex"
  janeDoe.lastName.log(); // prints "Smith"
}

By adding a setter method to the Person class, we need to change the implementation of a few things such as the constructor because providing a setter for an instance variable requires you to define a getter for it as well. The instance variable from that point on will need to be managed by setters and getters and cannot be a normal instance variable anymore.

Methods

Methods are functions, like constructors, that are define on an instance level. For example, a Car class might have a break() function that causes that particular car to hit the breaks. Imagine if I hit the break on my car and it hit the break simultaneously in your car too? What a disaster. That would be the job of a static method which we will talk about soon.

The anatomy of a method is like the anatomy of a normal function. You specify the result of the function + the name of the function in camelCase and any parameters inside parenthesis, and follow the whole thing with opening and closing curly brackets as shown here:

// a simple car class with acceleration
// and break support
class Car {
  void hitBreaks() {
    // do your work here
  }
  void accelerate() {
    // do the acceleration here
  }
}

Instance methods as their names indicate operate on an instance. It means that they affect only the instance of the class they are declared on. So my breaking the acceleration won’t affect your instance and vice versa. Here is how that looks like in code:

class Car {
  // a car class with a name that
  // can accelerate!
  final String name;
  const Car(this.name);

  // this is our method
  void accelerate() {
    '$name is accelerating'.log();
  }
}

// the "myCar" variable accelerating does
// not cause "yourCar" to accelerate
// and vice versa
void testIt() {
  const myCar = Car('Benz');
  myCar.accelerate(); // Benz is accelerating
  const yourCar = Car('BMW');
  yourCar.accelerate(); // BMW is accelerating
}

You can see how accelerating myCar won’t cause yourCar to accelerate and vice versa. That’s the point of instance methods!

Static Methods

While instance methods work on an instance level, static methods work on the class level and only have access to other static properties or methods. Here is an example where we have a Car class which upon instantiation, increments the total number of cars sold. The total number of cars sold is then stored at the Car class level, and not at the instance level:

// for the purpose of demonstration only
// you usually won't write code like this
class Car {
  // a private static variable to keep hold
  // of the number of cars sold/created
  static int _carsSold = 0;
  // a public static getter to get the number of cars sold
  static int get carsSold => _carsSold;
  // a public static setter to increment the number of cars sold
  static void incrementCarsSold() => _carsSold++;

  final String name;
  Car(this.name) {
    // increment the number of cars sold upon
    // construction of an instance of Car
    Car.incrementCarsSold();
  }
}

void testIt() {
  // create 3 instances of Car
  final myCar = Car('Benz');
  final yourCar = Car('BWM');
  final hisCar = Car('Audi');
  Car.carsSold.log(); // prints 3
  // 🚨 compile error: carsSold is available only
  // at the Car class-level, as a static getter
  myCar.carsSold;
}

Static methods have access to other static methods and variables (or their setters and getters). That means static methods do not have access to instance variables or instance methods but the other way around is not true. Instance variables and methods do have access to static variables and methods.

Factory Constructors

Whereas normal constructors always create a new instance of the class on which they are implemented, factory constructors can either create a new instance of the class, or they can return a cached version or even an instance of the sub-type of the class. Let’s have a look at an example where a factory constructor does create a new instance of the class:

// define a list of office jobs that are done by a solo
// person, meaning that we assume that we can only have
// 1 person at front desk, 1 manager and 1 accountant
enum SoloOfficeJob { frontDesk, manager, accountant }

class OfficeWorker {
  // hold onto a list of solo workers whose
  // job type is of type SoloOfficeJob
  static List<OfficeWorker> soloWorkers = [];

  // every instance of OfficeWorker holds onto
  // an optional solo job type
  final SoloOfficeJob? job;

  // the factory constructor for our class
  // that takes in an optional job of type SoloOfficeJob
  factory OfficeWorker.newInstance(SoloOfficeJob? job) {
    // can we find the given job type in the cache?
    final cached = soloWorkers.firstWhereOrNull(
      (element) => element.job == job,
    );
    if (cached != null) {
      // we found the job type in our cache, this
      // means we have already created an instance
      // of OfficeWorker with the given job type
      'Returned from cache'.log();
      return cached;
    } else {
      // we didn't find the job type in our cache
      // this means we need to create a new instance
      final newInstance = OfficeWorker(job);
      if (job != null) {
        // don't forget to add the new instance to the cache
        soloWorkers.add(newInstance);
      }
      'New instance created'.log();
      // finally return the new instance
      return newInstance;
    }
  }

  // we also need a constructor that our factory
  // constructor can call to create a new instance
  const OfficeWorker(this.job);
}

Now that we have our factory constructor, we can invoke it in order to create new instances of our class and ensure our cache is hit:

void testIt() {
  // creates a new instance and caches it since
  // the job is a solo-job type of manager
  final newManager = OfficeWorker.newInstance(
    SoloOfficeJob.manager,
  );
  // this will return a cached manager from the
  // previous call
  final cachedManager = OfficeWorker.newInstance(
    SoloOfficeJob.manager,
  );
  // new instance created since the job type is not cacheable
  final nonCachedNonSoloJob = OfficeWorker.newInstance(null);
  // another new instance created
  final anotherNonCachedJob = OfficeWorker.newInstance(null);
}

Factory constructors can be said to have super-powers when they are compared with normal constructors. Normal constructors have to just initialize an instance of the current class and be done with it while factory constructors can return a whole new instance as their result. Normal constructors cannot return an instance, they work with the current instance by default!

Extending Classes

Extending a class is the process of adding functionality to that class after it has been sealed close. Let’s say that you have a Person class again:

// a Person class with first
// and last name properties
// this class is missing the fullName property
// the class author might have not felt it
// necessary to add this property to the class
class Person {
  final String firstName;
  final String lastName;

  const Person(this.firstName, this.lastName);
}

If you or someone else as the class author feels it not required to have a fullName property in the original definition of the class, you can always extend the class using an extension and add that property as shown here:

extension FullName on Person {
  String get fullName => '$firstName $lastName';
}

void testIt() {
  const johnDoe = Person('John', 'Doe');
  johnDoe.fullName.log(); // prints "John Doe"
}

As you’ve already noted, every extension has to have a name in Dart, and those names cannot be duplicated in the same scope, meaning that I cannot have another extension now called FullName on any other class in the current scope as long as the previous FullName extension is available in the scope:

// 🚨 Extension FullName is already defined
// This throws a compile-time error
extension FullName on String {
  // empty for now
}

Immutable Classes

Some programming languages have support for pure immutable objects / structs / classes. An example is Rust where your values are stored inside an instance of a struct and you control whether that instance is mutable or immutable using the mut keyword.

In Dart, immutability on a class is still not fully supported and is only controlled using a linter rule. You can mark a class in Dart as immutable using the @immutable flag as shown here:

@immutable
// ⚠️ throws a linter warning saying that the
// person class is marked as immutable but
// one or more of its members are not immutable!
class Person {
  String firstName;
  String lastName;

  Person(this.firstName, this.lastName);
}

To solve this issue, you will need to ensure that this class is really not mutable and turn all the instance variables to final variables as shown here:

// an immutable class where all instance variables
// are marked as final and cannot be modified
// after the instance is created
@immutable
class Person {
  final String firstName;
  final String lastName;

  const Person(this.firstName, this.lastName);
}

Immutable classes in Dart also throw a linter warning for their subclasses if those subclasses break the rules of immutability:

// ⚠️ throws a warning saying that the class Person
// is marked as immutable but the Mom class
// has one or more immutable fields
// pointing to the "age" property whose value
// might change after the instantiation of the Mom class
class Mom extends Person {
  int age = 30;
  Mom() : super('Mom', 'Mom');
}

Inheritance

Inheritance is the process of a class inheriting logic from another class and is usually done through the use of the extends keyword. Let’s say that you have a class called Vehicle that can accelerate through an instance method:

// a simple vehicle class
// with a "kind" variable of type String
// that is capable of accelerating
@immutable
class Vehicle {
  final String kind;

  const Vehicle(this.kind);

  void accelerate() {
    '$kind is accelerating'.log();
  }
}

Now you want to go ahead and create 3 motorcycle instances out of this class like so:

// we are pretty much repeating the line
// where we create a motorcycle by calling
// the constructor to Vechicle
// and passing "Motorcycle" as the kind
const motorCycle1 = Vehicle('Motorcycle');
motorCycle1.accelerate();
const motorCycle2 = Vehicle('Motorcycle');
motorCycle2.accelerate();
const motorCycle3 = Vehicle('Motorcycle');
motorCycle3.accelerate();

Instead of repeating ourselves, let’s create a new class called Motorcycle that inherits from the Vehicle class:

// this class now inherits its logic from the
// Vehicle class and also passes the kind of
// "Motorcycle" to the Vehicle constructor
@immutable
class Motorcycle extends Vehicle {
  const Motorcycle() : super('Motorcycle');
}

With the Motorcycle class ready now, we can change our previous code to use Motorcycle instead of Vehicle:

// we no longer need to create an instance
// of a Vehicle class to use its methods
const motorCycle1 = Motorcycle();
motorCycle1.accelerate();
const motorCycle2 = Motorcycle();
motorCycle2.accelerate();
const motorCycle3 = Motorcycle();
motorCycle3.accelerate();

When you extend a class, you inherit its public instance variables and methods!

Abstract Classes

Abstract classes are also called interfaces (in Java and TypeScript) or protocols (in Swift) or traits (in Rust) and might have other names in other languages. They are blueprints of how a class should be formed and a class that implements that abstract will need to implement the blueprint’s methods and variables. Let’s have a look at an example:

// a blueprint of any vehicle that
// we can have in our application
abstract class Vehicle {
  // dictate that any vehicle must have
  // a kind property of type VehicleKind
  final VehicleKind kind;
  // use the constant constructor to set
  // the value of the "kind" property
  const Vehicle(this.kind);
  // we also dictate in this blueprint that
  // any class that implements our abstract
  // class has to have these two instance methods
  void accelerate();
  void decelerate();
}

After you have your abstract class, you can go ahead and create concrete implementations of it using class:

// a car class that implements the Vehicle
// abstract class. Note that we are not extending
// the Vehicle class here, but instead implementing
// it meaning that we are following the blueprint rather
// than adding functionality to the blueprint!
class Car implements Vehicle {
  // we must define the accelerate function
  @override
  void accelerate() {
    'Car is accelerating'.log();
  }

  // just like accelerate(), we must define the decelerate function
  @override
  void decelerate() {
    'Car is decelerating'.log();
  }

  // and we lock the "kind" variable to car
  @override
  VehicleKind get kind => VehicleKind.car;
}

After having the Car class we can now instantiate it and use its methods and variables:

void testIt() {
  // create a new instance of our car class
  final car = Car();
  // accelerate on it, then decelerate and
  // get the value of the "kind" property
  // and log it out to the debug console
  car
    ..accelerate()
    ..decelerate()
    ..kind.log();
}

Where to Go From Here

It’s best that you put your knowledge to test now and create a lot of classes and experiment with the features that you learnt about in this article. Don’t forget to read the official documentations for classes.

8 thoughts on “Dart: Classes

        1. Okay great to know that Abid, I will add that to my list of things to write about in this blog. Thank you again

Comments are closed.