Ankit Ranjan
Back to Deep Dives

Classes in Dart — Constructors, Inheritance, and Object Identity

How Dart organises code into objects, the many ways to construct them, and what really happens when we say two things are equal.

May 23, 2026 7 topics 7 quiz questions
Share:
1

Why classes exist

We have functions. We have data. Why do we need another concept?

The answer is coupling. Some data and some functions belong together. A user has a name and an email. A user can update their profile. A user can log out. The data and the behaviour are inseparable.

// Without classes — data and functions are separate
var userName = 'Ankit';
var userEmail = 'ankit@example.com';

void updateEmail(String newEmail) {
  userEmail = newEmail;   // which user? unclear
}

// With a class — data and behaviour travel together
class User {
  String name;
  String email;

  User(this.name, this.email);

  void updateEmail(String newEmail) {
    email = newEmail;   // obviously this user's email
  }
}
A class is a blueprint. It describes what data an object will hold (fields) and what it can do (methods). An object is a specific instance of that blueprint — a real user with a real name.

Class = blueprint, Object = instanceclass UserString nameString emailupdateEmail()toString()blueprintnewnewUser objectname: "Ankit"email: "ankit@..."User objectname: "Priya"email: "priya@..."Each object has:• Its own copy of fields• Shared method code• Its own identityObjects are independent.Changing one doesn'taffect the other.

The key insight is that each object gets its own copy of the data, but they all share the same methods. Ankit's email and Priya's email are stored separately. But when either calls updateEmail(), they're running the same code.

2

Constructors — the many ways to build objects

A constructor is a special function that creates an object. Dart gives us several flavours, each solving a different problem.

The generative constructor — the default. It creates a new instance every time.

class Point {
  final double x;
  final double y;

  // Long form
  Point(double x, double y) : x = x, y = y;

  // Short form — same thing
  Point(this.x, this.y);
}

var p = Point(3, 4);
The this.x syntax is Dart's shorthand for "assign the parameter to the field of the same name." It's so common that the long form is rarely seen.

Named constructors — when one constructor isn't enough.

class Point {
  final double x;
  final double y;

  Point(this.x, this.y);

  Point.origin() : x = 0, y = 0;

  Point.fromJson(Map<String, double> json)
      : x = json['x']!,
        y = json['y']!;
}

var p1 = Point(3, 4);
var p2 = Point.origin();
var p3 = Point.fromJson({'x': 1, 'y': 2});
Factory constructors — when you need control over what gets returned.

class Logger {
  static final Logger _instance = Logger._internal();

  factory Logger() {
    return _instance;   // always returns the same object
  }

  Logger._internal();   // private constructor
}

var a = Logger();
var b = Logger();
print(identical(a, b));   // true — same object
A factory constructor can return an existing instance, a subtype, or even null (if the return type is nullable). Regular constructors always create a new instance of exactly that class.

Constructor types at a glancePropertyGenerativePoint(x, y)NamedPoint.origin()Factoryfactory Point()Always creates new instance?YesYesNoCan return existing instance?NoNoYesCan return subtype?NoNoYesCan use initializer list?YesYesNo

Const constructors — compile-time canonicalisation.

class Point {
  final double x;
  final double y;

  const Point(this.x, this.y);
}

const p1 = Point(0, 0);
const p2 = Point(0, 0);
print(identical(p1, p2));   // true — same canonical instance
When all fields are final and the constructor is marked const, Dart can create the object at compile time. Two const Point(0, 0) expressions anywhere in the program will be the exact same object in memory.

3

Initializer lists and assertions

Sometimes we need to do work before the constructor body runs — setting final fields, calling superclass constructors, or validating inputs. That's what the initializer list is for.

class Rectangle {
  final double width;
  final double height;
  final double area;

  Rectangle(this.width, this.height)
      : area = width * height,           // computed field
        assert(width > 0),               // validation
        assert(height > 0);
}
The initializer list runs before the constructor body. This matters for final fields — they must be set before the body runs, so computed values go in the initializer list.

Order matters:
1. Initializer list (left to right)
2. Superclass constructor
3. Constructor body

class Animal {
  final String name;
  Animal(this.name) {
    print('Animal constructor');
  }
}

class Dog extends Animal {
  final String breed;

  Dog(String name, this.breed)
      : super(name) {    // calls Animal(name)
    print('Dog constructor');
  }
}

var d = Dog('Buddy', 'Labrador');
// prints: Animal constructor
// prints: Dog constructor
Assertions in initializer lists. The assert statements only run in debug mode. They're stripped out of release builds, so they're free in production. Use them liberally to catch bugs early.

class Percentage {
  final double value;

  Percentage(this.value)
      : assert(value >= 0 && value <= 100,
            'Percentage must be 0-100, got $value');

4

Inheritance — extending behaviour

Inheritance lets us build new classes on top of existing ones. The child class gets all the parent's fields and methods, and can add or override them.

class Animal {
  final String name;
  Animal(this.name);

  void speak() => print('...');
}

class Dog extends Animal {
  Dog(String name) : super(name);

  @override
  void speak() => print('$name says woof!');
}

class Cat extends Animal {
  Cat(String name) : super(name);

  @override
  void speak() => print('$name says meow!');
}

void main() {
  List<Animal> pets = [Dog('Buddy'), Cat('Whiskers')];
  for (var pet in pets) {
    pet.speak();   // polymorphism — right method called
  }
}
// Buddy says woof!
// Whiskers says meow!
Inheritance hierarchy — Dog and Cat extend AnimalAnimalname: Stringspeak() → "..."extendsextendsDoginherits: name@override speak()→ "$name says woof!"Catinherits: name@override speak()→ "$name says meow!"

The @override annotation. It's optional but highly recommended. It tells Dart (and other developers) that we're intentionally replacing a parent method. If we misspell the method name, the compiler will warn us.

Calling the superclass. Inside an overridden method, we can call the parent's version with super.

class LoggingDog extends Dog {
  LoggingDog(String name) : super(name);

  @override
  void speak() {
    print('About to speak...');
    super.speak();   // calls Dog.speak()
    print('Done speaking.');
  }
}

5

Abstract classes and interfaces

Sometimes we want to define a contract without providing the implementation. That's what abstract classes are for.

abstract class Shape {
  double get area;           // no body — must be implemented
  double get perimeter;

  void describe() {          // has a body — inherited as-is
    print('Area: $area, Perimeter: $perimeter');
  }
}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);

  @override
  double get area => 3.14159 * radius * radius;

  @override
  double get perimeter => 2 * 3.14159 * radius;
}
An abstract class cannot be instantiated directly. Shape() is a compile error. But we can extend it, and then create instances of the concrete subclass.

Interfaces — implicit in Dart. Every class automatically defines an interface. Any class can implement any other class's interface.

class Printable {
  void printSelf() => print(toString());
}

class Report implements Printable {
  final String title;
  Report(this.title);

  @override
  void printSelf() => print('Report: $title');

  @override
  String toString() => 'Report($title)';
}
extends vs implements:
extends — inherit implementation (fields, method bodies)
implements — promise to provide the interface (must override everything)

A class can extend one class but implement many interfaces. This gives us single inheritance with multiple interface conformance.

abstract class Flyable {
  void fly();
}

abstract class Swimmable {
  void swim();
}

class Duck extends Animal implements Flyable, Swimmable {
  Duck(String name) : super(name);

  @override
  void fly() => print('$name is flying');

  @override
  void swim() => print('$name is swimming');
}

6

Object equality — == vs identical()

When are two objects "the same"? Dart gives us two different answers.

identical() — are they the exact same object in memory?

var a = Point(3, 4);
var b = Point(3, 4);
var c = a;

print(identical(a, b));   // false — different objects
print(identical(a, c));   // true — same object
== operator — are they meaningfully equal?

By default, == does the same thing as identical(). But we can override it to define our own equality.

class Point {
  final double x;
  final double y;

  Point(this.x, this.y);

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Point && other.x == x && other.y == y;
  }

  @override
  int get hashCode => Object.hash(x, y);
}

var a = Point(3, 4);
var b = Point(3, 4);
print(a == b);            // true — same values
print(identical(a, b));   // false — different objects
Identity vs Equalityidentical(a, b)Same object in memory?• Checks memory address• Cannot be overridden• Always O(1)Use for: caching, singletonsa == bMeaningfully equal?• Checks logical equality• Override operator ==• Must also override hashCodeUse for: value comparison

The hashCode contract. If a == b, then a.hashCode == b.hashCode. This is required for Sets and Maps to work correctly. If two objects are equal, they must have the same hash code. The reverse isn't required — different objects can have the same hash code (that's a collision, and it's fine).

7

Getters, setters, and encapsulation

Fields expose data directly. Sometimes we want more control — validation, computation, or logging. That's where getters and setters come in.

class Temperature {
  double _celsius;   // private field (underscore prefix)

  Temperature(this._celsius);

  // Getter — computed property
  double get fahrenheit => _celsius * 9 / 5 + 32;

  // Setter — with validation
  set celsius(double value) {
    if (value < -273.15) {
      throw ArgumentError('Below absolute zero!');
    }
    _celsius = value;
  }

  double get celsius => _celsius;
}

var temp = Temperature(20);
print(temp.fahrenheit);   // 68.0
temp.celsius = 25;        // uses the setter
temp.celsius = -300;      // throws ArgumentError
The underscore convention. In Dart, a leading underscore makes a name library-private. It's not accessible from other files. This is how we hide implementation details.

// In user.dart
class User {
  String _password;   // private — other files can't access

  User(this._password);

  bool checkPassword(String attempt) {
    return attempt == _password;
  }
}

// In main.dart
var user = User('secret123');
print(user._password);        // ERROR — can't access
print(user.checkPassword('secret123'));   // true
Final fields vs getters. For simple read-only data, a final field is simpler. Use a getter when the value is computed or when you might need to add logic later.

class Circle {
  final double radius;     // simple read-only field

  Circle(this.radius);

  double get area => 3.14159 * radius * radius;   // computed
  double get circumference => 2 * 3.14159 * radius;
}
The caller doesn't know (or care) whether area is a stored field or a computed getter. That's encapsulation — we can change the implementation without changing the interface.

Test your understanding

7 questions

Seven questions covering classes, constructors, inheritance, and object equality in Dart.

Search

Loading search...