OOP in Dart #

Dart was designed from the start as a pure object-oriented programming language. In the Dart ecosystem, every value is an object — including basic types like numbers, functions, and even null values. Every object is an instance of a class, and all classes (except the Null type) fall under one main parent class: Object. Deeply understanding the pillars of OOP in Dart is the key to designing modular, structured, testable, and long-term maintainable Flutter app architectures.

The Concepts of Class, Object, and Encapsulation #

A Class is a blueprint or custom data type definition describing the data characteristics and behaviors of an entity. An Object is the concrete realization or physical instance of that class, occupying space in the app’s heap memory.

Encapsulation in Dart #

Encapsulation is the process of wrapping data (instance variables) and behavior (methods) in a single unit, while restricting direct access from outside the object to maintain internal state integrity.

Unlike languages like Java or C++ that use explicit access control keywords like public, private, or protected, Dart uses a simpler approach based on library-level privacy:

  • Public class members are written like regular variables.
  • Private class members are marked by adding an underscore (_) in front of the variable or method name. These private members can only be accessed by code within the same file (library).

Let’s look at a correct encapsulation implementation using getters and setters to maintain data integrity:

// ANTI-PATTERN: Allowing free internal data modification from outside the class
class BadProduct {
  String name;
  double price; // Prone to being changed to a negative value from outside!
  BadProduct(this.name, this.price);
}

// ====================================================================

// CORRECT: Using encapsulation, private variables, and setter validation
class Product {
  final String id;
  final String name;
  double _price; // Private: Only accessible in this file

  Product(this.id, this.name, double price) : _price = price {
    // Validation during object initialization
    if (price < 0) throw ArgumentError('Price cannot be negative');
  }

  // Getter: Providing controlled read access to the outside world
  double get price => _price;

  // Setter: Validating every attempt to change the price value
  set price(double newPrice) {
    if (newPrice < 0) {
      throw ArgumentError('New price cannot be negative');
    }
    _price = newPrice;
  }

  // Method to safely modify internal state
  void applyDiscount(double percentage) {
    if (percentage < 0 || percentage > 100) {
      throw ArgumentError('Invalid discount percentage');
    }
    _price = _price * (1 - percentage / 100);
  }
}

Breaking Down the 5 Constructor Types in Dart #

A Constructor is the special method called first when creating a new object instance from a class. Dart offers very high flexibility by providing 5 different constructor types:

1. Generative Constructor #

The standard constructor most often used to create new object instances and directly assign parameter values to instance variables.

class Point {
  final double x;
  final double y;
  
  // Using the 'this' shorthand syntax
  Point(this.x, this.y);
}

2. Named Constructor #

Lets you define several constructors with different names inside the same class to make object initialization easier across various data scenarios.

class Point {
  final double x;
  final double y;

  Point(this.x, this.y);

  // Named constructor: Creating a point at the origin coordinates
  Point.origin() : x = 0, y = 0;

  // Named constructor: Creating an object from JSON data
  Point.fromJson(Map<String, dynamic> json)
      : x = json['x'] as double,
        y = json['y'] as double;
}

3. Const Constructor #

If your class stores immutable data (all fields marked final), you can add the const keyword in front of the constructor. Objects created with const become compile-time constants.

In Flutter, using const is crucial in widget trees because it tells Flutter the widget doesn’t need to be rebuilt during screen re-renders, significantly improving rendering performance.

class ThemeColor {
  final int hex;
  // Creating a compile-time constant object
  const ThemeColor(this.hex);
}

void testConst() {
  const color1 = ThemeColor(0xFFFFFFFF);
  const color2 = ThemeColor(0xFFFFFFFF);
  
  // Both point to the exact same memory reference (canonical instance)
  print(identical(color1, color2)); // Output: true
}

4. Factory Constructor #

A constructor using the factory keyword gives you full control over object creation. Unlike a regular generative constructor that must create a new object, a factory constructor can return an object retrieved from memory cache, or even return an instance of a different subclass.

The difference in object creation flow can be visualized as follows:

flowchart TD
    Client["Caller: Object Creation"] --> Choice{"Choose Constructor"}
    Choice -->|"Generative Constructor"| Gen["Allocate New Memory on Heap"]
    Gen --> Init["Run Initializer List & Constructor Body"]
    Init --> ReturnNew["Return New Object"]
    
    Choice -->|"Factory Constructor"| Fact{"Creation Condition"}
    Fact -->|"In Cache"| Cache["Get Object from Cache"]
    Fact -->|"Create New / Subclass"| NewSub["Create New / Subclass Instance"]
    Cache & NewSub --> ReturnFact["Return the Appropriate Instance"]

Let’s look at an example of creating a logger cache using a factory:

class Logger {
  final String name;
  static final Map<String, Logger> _cache = {};

  // Internal private generative constructor
  Logger._internal(this.name);

  // Factory constructor controlling object return
  factory Logger(String name) {
    // Return the object from cache if it was created before
    return _cache.putIfAbsent(name, () => Logger._internal(name));
  }
}

5. Redirecting Constructor #

A constructor without its own body that only delegates the object initialization task to another constructor in the same class to avoid code duplication.

class Point {
  final double x;
  final double y;

  Point(this.x, this.y);

  // Redirecting the y coordinate point initialization to the main constructor
  Point.horizontal(double x) : this(x, 0);
}

Initializer Lists and Assertions #

An initializer list is a list of expressions executed before the constructor body runs. It’s ideal for validating input using assert or assigning final field values based on parameter calculations:

class Circle {
  final double radius;
  final double area;

  // Initializer list validates data and computes the area
  Circle(double r)
      : assert(r > 0, 'Circle radius must be positive'),
        radius = r,
        area = 3.14 * r * r;
}

Single Inheritance #

Inheritance allows a new class (called a sub-class or child class) to inherit properties and methods from an existing class (called a base class or parent class) using the extends keyword.

Dart follows a Single Inheritance system, meaning a class is only allowed to inherit from at most one parent class directly. This is done to avoid code name conflicts between two different parents (the Diamond Problem).

Using super and Super Parameters #

A subclass can access and modify parent methods with the super keyword. Since Dart 2.17, you can use the Super Parameters feature to directly pass subclass constructor parameter values to the parent class concisely:

// Base Class
class Employee {
  final String name;
  final double salary;

  Employee(this.name, this.salary);

  void work() => print('$name is working.');
}

// Subclass
class Developer extends Employee {
  final String programmingLanguage;

  // Using 'super.name' and 'super.salary' to pass to the parent constructor
  Developer({
    required super.name,
    required super.salary,
    required this.programmingLanguage,
  });

  // Overriding the parent method
  @override
  void work() {
    super.work(); // Running the parent logic
    print('Writing program code using the $programmingLanguage language.');
  }
}

Abstract Classes and Implicit Interfaces #

When designing code, you often need a class that acts as a standardization contract without providing detailed implementations.

Abstract Classes #

A class marked with the abstract keyword cannot be instantiated directly using the new keyword. This class is useful for defining abstract methods (methods without code bodies) that all concrete subclasses must complete.

abstract class Storage {
  // Abstract methods without implementations
  Future<void> write(String key, String value);
  Future<String?> read(String key);
}

// Subclass must implement the abstract methods above
class SecureStorage extends Storage {
  @override
  Future<void> write(String key, String value) async {
    // Data encryption implementation
  }

  @override
  Future<String?> read(String key) async {
    // Data decryption implementation
    return 'data';
  }
}

Implicit Interfaces #

One unique characteristic distinguishing Dart from Java or C# is that Dart doesn’t have a special interface keyword (except the modern abstract interface class modifier). In Dart, every class implicitly acts as an interface.

Every time a class uses the implements keyword (instead of extends), the class must re-implement all properties and methods of the target class from scratch, without inheriting existing implementations.

class MockStorage implements Storage {
  // Must rewrite the implementation even though the parent has concrete methods
  @override
  Future<void> write(String key, String value) async => print('Mock Write');

  @override
  Future<String?> read(String key) async => 'mock_data';
}

When to Use extends vs implements? #

  • Use extends if you want to build an is-a specialization relationship and want to leverage code logic inheritance from the parent class.
  • Use implements if you only want to adopt the data type contract without using the code logic inside, very useful when creating mock objects for unit testing.

Mixins — Capability Composition Without Inheritance #

Because Dart only supports single inheritance, sharing a set of helper methods across various classes in different hierarchy branches becomes a challenge. This is where Mixins come in as the savior.

Mixins are a way to share behavior code across classes without using class inheritance. A class can use a mixin with the with keyword.

The architectural difference between single inheritance and mixin composition can be visualized in the diagram below:

flowchart TD
    subgraph SingleInheritance["Single Inheritance (extends)"]
        direction TB
        Base["Base Class: Vehicle"] --> Sub["Sub-class: Car"]
        Sub --> SubSub["Sub-class: ElectricCar"]
    end
    subgraph MixinComposition["Mixin Composition (with)"]
        direction TB
        Main["Class: UserService"]
        M1["Mixin: Logging"]
        M2["Mixin: Cacheable"]
        M1 & M2 -.->|"Inserted (with)"| Main
    end

Mixin Implementation Example #

Let’s create mixins for handling logging and data validation:

mixin Logger {
  void logInfo(String message) {
    print('[INFO - ${DateTime.now()}]: $message');
  }
}

mixin Validator {
  bool isValidEmail(String email) {
    return email.contains('@');
  }
}

// Using mixins across class hierarchies without inheritance
class AuthService with Logger, Validator {
  void register(String email) {
    if (isValidEmail(email)) {
      logInfo('Registration successful for $email');
    } else {
      print('Invalid email');
    }
  }
}

Restricting Mixins with the on Keyword #

You can restrict a mixin to only be usable on classes that inherit from a specific class using the on keyword:

import 'package:flutter/material.dart';

// This mixin can ONLY be used by classes that are subclasses of State (Flutter)
mixin LoadingStateMixin<T extends StatefulWidget> on State<T> {
  bool isLoading = false;

  void toggleLoading() {
    setState(() {
      isLoading = !isLoading;
    });
  }
}

Generics — Writing Flexible and Type-Safe Code #

Generics is a feature that lets you write classes, interfaces, or methods whose behavior can be adapted for various data types, while maintaining type safety at compile time.

Generics use a type parameter placed inside angle brackets <T>:

// Creating a generic Stack data structure
class Stack<T> {
  final List<T> _items = [];

  void push(T value) => _items.add(value);

  T pop() {
    if (_items.isEmpty) throw StateError('Stack is empty');
    return _items.removeLast();
  }
}

void main() {
  // A Stack specifically for holding Numbers
  final numberStack = Stack<int>();
  numberStack.push(10);
  // numberStack.push('Text'); // ERROR compile-time! Prevents bugs from the start

  // A Stack specifically for holding Text
  final textStack = Stack<String>();
  textStack.push('Hello');
}

Bounded Generics #

You can restrict what data types are allowed into generic parameters using the extends keyword:

// Only allowing the num type (int/double) into this box
class CalculatorBox<T extends num> {
  final T value;
  CalculatorBox(this.value);

  double get half => value / 2;
}

Operator Overloading and Extension Methods #

Dart provides two additional syntax sugar features to make your code feel more natural and integrated with built-in language features:

1. Operator Overloading #

You can redefine the behavior of mathematical or comparison operators (+, -, ==, [], etc.) for your own classes:

class Vector2D {
  final double x;
  final double y;

  const Vector2D(this.x, this.y);

  // Defining the addition operator (+)
  Vector2D operator +(Vector2D other) {
    return Vector2D(x + other.x, y + other.y);
  }

  // Defining the equality comparison operator (==)
  @override
  bool operator ==(Object other) =>
      other is Vector2D && x == other.x && y == other.y;

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

  @override
  String toString() => 'Vector2D($x, $y)';
}

void main() {
  final v1 = Vector2D(2.0, 3.0);
  final v2 = Vector2D(4.0, 1.0);
  
  // Using the custom + operator naturally
  final v3 = v1 + v2; 
  print(v3); // Output: Vector2D(6.0, 4.0)
}

2. Extension Methods #

Extension methods let you add new functions or methods to existing classes — even built-in classes from the Dart SDK or third parties whose code you can’t modify directly:

// Adding capitalization capability to the built-in String data type
extension StringCapitalizeExtension on String {
  String toCapitalized() {
    if (isEmpty) return this;
    return '${this[0].toUpperCase()}${substring(1)}';
  }
}

void main() {
  final myText = 'belajar flutter';
  // Calling the new extension method
  print(myText.toCapitalized()); // Output: Belajar flutter
}

Summary #

  • Encapsulation: Use the underscore prefix (_) to restrict variable access at the library level (library-level privacy) and manage read-write access using Getters and Setters.
  • Constructors: Dart provides 5 custom constructors. Always prioritize const constructors for widget components in Flutter to minimize screen rendering load.
  • Inheritance: Use single inheritance via extends to share concrete logic from the parent class vertically.
  • Abstract Classes & Interfaces: Abstract classes define partial contracts, while every class in Dart acts as an implicit interface when used with the implements keyword.
  • Mixins: A horizontal code composition solution using with to share capabilities across hierarchies without facing the Diamond Problem.
  • Generics: Brings flexible, reusable component code writing while staying safe from data type errors (type safety).
  • Extension & Overloading: Provides flexibility to add functions to ready-made libraries (Extensions) and allows modifying built-in operators (Overloading).

← Previous: Collections   Next: Functional Programming →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact