Classes #

Classes are the most important code organization unit in object-oriented programming — they combine data (properties) and behavior (methods) into one cohesive entity. In Dart, classes are the main mechanism for defining new types, and almost every value in a Dart program is an instance of some class. What sets Dart classes apart from other languages are several unique features: library-based privacy (not a keyword), the implicit interface of every class, mixins as a reuse unit separate from inheritance, and extension methods for adding functionality to existing classes without modification. This article covers all these mechanisms from the most basic to the design patterns used in real applications.

Anatomy of a Class #

A Dart class can contain six kinds of members: instance properties, static properties, constructors, getters/setters, instance methods, and static methods.

class Produk {
  // 1. Instance properties — owned by every object
  final String id;
  String nama;
  double harga;

  // 2. Static properties — owned by the class, not objects
  static int totalDibuat = 0;
  static const double pajakDefault = 0.11;

  // 3. Main constructor
  Produk({required this.id, required this.nama, required this.harga}) {
    totalDibuat++;
  }

  // 4. Getter — a computed property
  double get hargaDenganPajak => harga * (1 + pajakDefault);
  bool get tersedia => stok > 0;

  // 5. Setter — validation on assignment
  int _stok = 0;
  int get stok => _stok;
  set stok(int nilai) {
    if (nilai < 0) throw ArgumentError('Stock cannot be negative');
    _stok = nilai;
  }

  // 6. Instance method
  Produk salinDengan({String? nama, double? harga}) {
    return Produk(
      id: id,
      nama: nama ?? this.nama,
      harga: harga ?? this.harga,
    );
  }

  // Static method
  static void resetHitung() => totalDibuat = 0;

  @override
  String toString() => 'Produk($id: $nama, Rp${harga.toStringAsFixed(0)})';
}
classDiagram
    class Produk {
        +String id
        +String nama
        +double harga
        -int _stok
        +static int totalDibuat
        +double hargaDenganPajak
        +bool tersedia
        +int stok
        +salinDengan() Produk
        +static resetHitung()
        +toString() String
    }

Constructors and Their Variants #

Dart provides several forms of constructors, each with a different purpose. Knowing when to use each is key to good class design.

Main Constructors #

The this.namaProperti syntax in constructor parameters is an initializing formal — a shorthand for initializing properties without writing this.x = x in the body:

class Titik {
  final double x;
  final double y;

  // Initializing formal — concise and idiomatic
  Titik(this.x, this.y);

  // Equivalent to:
  // Titik(double x, double y) : this.x = x, this.y = y;
}

// Named parameters for a more descriptive constructor
class Pengguna {
  final String id;
  final String nama;
  final String email;
  final DateTime bergabung;

  Pengguna({
    required this.id,
    required this.nama,
    required this.email,
    DateTime? bergabung,
  }) : bergabung = bergabung ?? DateTime.now();
  // ↑ initializer list — executed BEFORE the constructor body
}

Initializer Lists #

The initializer list (the part after : before the body) is used to initialize final properties with expressions more complex than plain parameters:

class Segitiga {
  final double alas;
  final double tinggi;
  final double luas;        // final — must be initialized in the initializer list
  final double keliling;

  Segitiga(this.alas, this.tinggi, double sisi)
      : luas = 0.5 * alas * tinggi,         // computed from other parameters
        keliling = alas + tinggi + sisi,     // can reference parameters
        assert(alas > 0 && tinggi > 0,       // assert in the initializer list
               'Base and height must be positive');
}

assert in the initializer list is the best way to validate constructor prerequisites — errors appear in debug mode (not production) with a clear message.

Named Constructors #

Named constructors allow multiple ways to create objects with different intents — each name communicates the context of its creation:

class Warna {
  final int r, g, b, a;

  // Main constructor
  const Warna(this.r, this.g, this.b, {this.a = 255});

  // Named constructor — from a hex value
  factory Warna.dariHex(String hex) {
    final h = hex.replaceAll('#', '');
    return Warna(
      int.parse(h.substring(0, 2), radix: 16),
      int.parse(h.substring(2, 4), radix: 16),
      int.parse(h.substring(4, 6), radix: 16),
    );
  }

  // Named constructor — predefined colors
  const Warna.merah() : r = 255, g = 0, b = 0, a = 255;
  const Warna.hijau() : r = 0, g = 255, b = 0, a = 255;
  const Warna.biru() : r = 0, g = 0, b = 255, a = 255;
  const Warna.transparan() : r = 0, g = 0, b = 0, a = 0;

  // Named constructor — copy with modifications
  Warna.dariWarna(Warna lain, {int? r, int? g, int? b, int? a})
      : r = r ?? lain.r,
        g = g ?? lain.g,
        b = b ?? lain.b,
        a = a ?? lain.a;
}

// Usage — every named constructor communicates its intent
const merah = Warna.merah();
final ungu = Warna.dariHex('#8B00FF');
final transparanMerah = Warna.dariWarna(merah, a: 128);

Factory Constructors #

A factory constructor doesn’t directly create a new instance — it can return an existing instance, an instance of a subclass, or the result of complex logic. The factory keyword signals to the reader that object creation involves special logic:

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

  // Private constructor — only the factory can create a Logger
  Logger._(this.nama);

  // Singleton per name — the factory returns an existing instance
  factory Logger(String nama) {
    return _cache.putIfAbsent(nama, () => Logger._(nama));
  }

  void log(String pesan) => print('[$nama] $pesan');
}

// Every call with the same name returns the same object
final loggerA = Logger('Auth');
final loggerB = Logger('Auth');
print(identical(loggerA, loggerB)); // true — singleton
// Factory constructor from JSON — a very common pattern
class Pengguna {
  final String id;
  final String nama;
  final String email;

  const Pengguna({required this.id, required this.nama, required this.email});

  factory Pengguna.dariJson(Map<String, dynamic> json) {
    return Pengguna(
      id: json['id'] as String,
      nama: json['nama'] as String,
      email: json['email'] as String,
    );
  }

  Map<String, dynamic> keJson() => {
    'id': id,
    'nama': nama,
    'email': email,
  };
}

Getters and Setters #

Getters and setters enable computed properties and validation on access — an interface that looks like a plain property but has logic behind it:

class RekeningBank {
  final String noRekening;
  double _saldo;

  RekeningBank({required this.noRekening, double saldoAwal = 0})
      : _saldo = saldoAwal;

  // Getter — read-only access to private data
  double get saldo => _saldo;

  // Computed getters
  bool get kaya => _saldo > 1_000_000_000;
  String get ringkasan => 'Rek ${noRekening}: Rp${_saldo.toStringAsFixed(0)}';

  // Setter with validation
  set setor(double jumlah) {
    if (jumlah <= 0) throw ArgumentError('Deposit amount must be positive');
    _saldo += jumlah;
  }

  // Method for operations that need two parameters
  void tarik(double jumlah, {String? keterangan}) {
    if (jumlah <= 0) throw ArgumentError('Withdrawal amount must be positive');
    if (jumlah > _saldo) throw StateError('Insufficient balance');
    _saldo -= jumlah;
  }
}
// ANTI-PATTERN: a setter that doesn't validate
class Siswa {
  int nilaiUjian = 0;   // ✗ anyone can set the score to -100 or 999
}

// CORRECT: a validating setter ensures class invariants
class Siswa {
  int _nilaiUjian = 0;

  int get nilaiUjian => _nilaiUjian;

  set nilaiUjian(int nilai) {
    if (nilai < 0 || nilai > 100) {
      throw RangeError.range(nilai, 0, 100, 'nilaiUjian');
    }
    _nilaiUjian = nilai;
  }
}

Immutable Classes and copyWith #

Immutable classes (all properties final) are a highly recommended pattern in Dart — especially for data models. Immutable objects are safe to use in many places without fear of unexpected changes, and easy to debug because their state doesn’t change after creation.

“Modifying” an immutable object is done by creating a new object via a copyWith method:

class PengaturanAplikasi {
  final String bahasa;
  final bool modeMalam;
  final double ukuranFont;
  final bool notifikasiAktif;

  const PengaturanAplikasi({
    this.bahasa = 'id',
    this.modeMalam = false,
    this.ukuranFont = 14.0,
    this.notifikasiAktif = true,
  });

  // copyWith — create a new object with some values changed
  PengaturanAplikasi copyWith({
    String? bahasa,
    bool? modeMalam,
    double? ukuranFont,
    bool? notifikasiAktif,
  }) {
    return PengaturanAplikasi(
      bahasa: bahasa ?? this.bahasa,
      modeMalam: modeMalam ?? this.modeMalam,
      ukuranFont: ukuranFont ?? this.ukuranFont,
      notifikasiAktif: notifikasiAktif ?? this.notifikasiAktif,
    );
  }

  @override
  bool operator ==(Object other) =>
      other is PengaturanAplikasi &&
      other.bahasa == bahasa &&
      other.modeMalam == modeMalam &&
      other.ukuranFont == ukuranFont &&
      other.notifikasiAktif == notifikasiAktif;

  @override
  int get hashCode =>
      Object.hash(bahasa, modeMalam, ukuranFont, notifikasiAktif);

  @override
  String toString() =>
      'PengaturanAplikasi(bahasa: $bahasa, modeMalam: $modeMalam)';
}

// Usage
final defaultPengaturan = PengaturanAplikasi();
final pengaturanMalam = defaultPengaturan.copyWith(modeMalam: true, ukuranFont: 16);
// defaultPengaturan is completely unchanged

Inheritance — extends #

Inheritance lets a new class inherit all properties and methods of another class, then extend or change its behavior. Dart only supports single inheritance — a class can only extends one other class.

abstract class Hewan {
  final String nama;
  final int umur;

  const Hewan({required this.nama, required this.umur});

  // Abstract method — MUST be implemented by subclasses
  String bersuara();

  // Concrete method — can be inherited directly
  String deskripsi() => '$nama ($umur tahun): ${bersuara()}';
}

class Kucing extends Hewan {
  final String warnaBulu;

  const Kucing({
    required super.nama,   // Dart 2.17+: super parameter
    required super.umur,
    required this.warnaBulu,
  });

  @override
  String bersuara() => 'Meow!';

  // Additional method — not in the superclass
  void mendengkur() => print('$nama mendengkur...');
}

class Anjing extends Hewan {
  final String ras;

  const Anjing({
    required super.nama,
    required super.umur,
    required this.ras,
  });

  @override
  String bersuara() => 'Woof!';

  @override
  String deskripsi() {
    // super.deskripsi() calls the superclass implementation
    return '${super.deskripsi()} [Ras: $ras]';
  }
}

void main() {
  final hewan = <Hewan>[
    Kucing(nama: 'Milo', umur: 3, warnaBulu: 'oranye'),
    Anjing(nama: 'Rex', umur: 5, ras: 'Labrador'),
  ];

  // Polymorphism — the called method is determined by the runtime type
  for (final h in hewan) {
    print(h.deskripsi());
  }
}
// ANTI-PATTERN: inheritance just for code reuse — the relationship isn't "is-a"
class Logger extends ArrayList<String> {  // ✗ a Logger isn't a kind of ArrayList
  void log(String pesan) => add(pesan);
}

// CORRECT: use composition when the relationship isn't "is-a"
class Logger {
  final List<String> _riwayat = [];  // has-a ArrayList, not is-a

  void log(String pesan) => _riwayat.add(pesan);
  List<String> get riwayat => List.unmodifiable(_riwayat);
}

Abstract Classes #

An abstract class defines a contract — what methods must exist — without providing a full implementation. It can’t be instantiated directly.

abstract class Repository<T, ID> {
  // The CRUD contract to implement
  Future<T?> cariById(ID id);
  Future<List<T>> cariSemua();
  Future<T> simpan(T entitas);
  Future<void> hapus(ID id);

  // Concrete method — can be inherited without overriding
  Future<bool> ada(ID id) async {
    return await cariById(id) != null;
  }
}

// Concrete implementation
class PenggunaSqlRepository extends Repository<Pengguna, String> {
  final Database _db;
  PenggunaSqlRepository(this._db);

  @override
  Future<Pengguna?> cariById(String id) async {
    final hasil = await _db.query('pengguna', where: 'id = ?', whereArgs: [id]);
    return hasil.isEmpty ? null : Pengguna.dariMap(hasil.first);
  }

  @override
  Future<List<Pengguna>> cariSemua() async {
    final hasil = await _db.query('pengguna');
    return hasil.map(Pengguna.dariMap).toList();
  }

  @override
  Future<Pengguna> simpan(Pengguna pengguna) async {
    await _db.insert('pengguna', pengguna.keMap(),
        conflictAlgorithm: ConflictAlgorithm.replace);
    return pengguna;
  }

  @override
  Future<void> hapus(String id) => _db.delete('pengguna', where: 'id = ?', whereArgs: [id]);
}

implements — Implementing an Interface #

In Dart, every class automatically defines an implicit interface — the set of its public methods and getters. implements requires a class to implement all public members of the specified class/interface, without inheriting their implementations.

// A regular class can be used as an interface
class Loggable {
  void log(String pesan) => print('[${runtimeType}] $pesan');
}

// An abstract class as a pure interface — more explicit
abstract class Serializable {
  Map<String, dynamic> keJson();
  String toJsonString();
}

// implements requires implementing ALL public members
class Transaksi implements Loggable, Serializable {
  final String id;
  final double jumlah;

  Transaksi({required this.id, required this.jumlah});

  @override
  void log(String pesan) => print('[Transaksi-$id] $pesan'); // override implementation

  @override
  Map<String, dynamic> keJson() => {'id': id, 'jumlah': jumlah};

  @override
  String toJsonString() => jsonEncode(keJson());
}

The Difference Between extends and implements #

class A {
  void hello() => print('Hello from A');
  void selamat() => print('Selamat from A');
}

// extends — inherits implementations, only overrides what's needed
class B extends A {
  @override
  void hello() => print('Hello from B'); // only this override
  // selamat() is inherited from A
}

// implements — MUST implement everything, nothing is inherited
class C implements A {
  @override
  void hello() => print('Hello from C'); // required
  @override
  void selamat() => print('Selamat from C'); // required
}
Aspectextendsimplements
Inherits implementations
Can override✓ (optional)✓ (all required)
Maximum count1 classunlimited
Main purposeSpecializationType contract

Mixins — Reuse Without Inheritance #

A mixin is a collection of methods and properties that can be “mixed into” a class without inheritance. Use mixins for capabilities that can be shared by various classes unrelated in the hierarchy:

mixin Validasi {
  // Mixins can have properties and methods
  List<String> _kesalahan = [];

  bool get valid => _kesalahan.isEmpty;
  List<String> get kesalahan => List.unmodifiable(_kesalahan);

  void tambahKesalahan(String pesan) => _kesalahan.add(pesan);
  void bersihkanKesalahan() => _kesalahan.clear();

  bool validasiTidakKosong(String nilai, String namaField) {
    if (nilai.trim().isEmpty) {
      tambahKesalahan('$namaField cannot be empty');
      return false;
    }
    return true;
  }
}

mixin Serialisasi {
  Map<String, dynamic> keJson();  // abstract — subclasses must implement

  String keJsonString() => jsonEncode(keJson());

  void dariJsonString(String jsonStr) {
    // common implementation
  }
}

// Using mixins with with
class FormPendaftaran with Validasi, Serialisasi {
  String nama = '';
  String email = '';

  bool validasi() {
    bersihkanKesalahan();
    validasiTidakKosong(nama, 'Nama');
    if (!email.contains('@')) tambahKesalahan('Email tidak valid');
    return valid;
  }

  @override
  Map<String, dynamic> keJson() => {'nama': nama, 'email': email};
}

Mixins with on — Restricting Targets #

on restricts a mixin so it can only be used by classes that extend/implements a certain class. This lets the mixin access members of the target class:

abstract class Widget {
  String get kunci;
  void render();
}

// This mixin can only be used by Widget or its subclasses
mixin AnimasiWidget on Widget {
  Duration get durasiAnimasi => Duration(milliseconds: 300);

  void renderDenganAnimasi() {
    print('Animating $kunci for ${durasiAnimasi.inMilliseconds}ms');
    render(); // can access Widget methods because of the 'on Widget' constraint
  }
}

class Tombol extends Widget with AnimasiWidget {
  @override
  final String kunci;
  Tombol(this.kunci);

  @override
  void render() => print('Render tombol: $kunci');
}

// A class that isn't a Widget CANNOT use AnimasiWidget
// class BukanWidget with AnimasiWidget {} // ✗ compile error

Extension Methods — Adding Capabilities Without Modification #

Extension methods let you add new methods to existing classes — including Dart’s built-in classes — without changing the original code and without inheritance:

// Extension on String
extension StringExtension on String {
  bool get isEmail => RegExp(r'^[\w\.-]+@[\w\.-]+\.\w{2,}$').hasMatch(this);
  bool get isPhoneNumber => RegExp(r'^\+?[\d\s\-]{10,}$').hasMatch(this);

  String capitalize() {
    if (isEmpty) return this;
    return '${this[0].toUpperCase()}${substring(1).toLowerCase()}';
  }

  String truncate(int maxLength, {String suffix = '...'}) {
    if (length <= maxLength) return this;
    return '${substring(0, maxLength - suffix.length)}$suffix';
  }
}

// Extension on List
extension ListExtension<T> on List<T> {
  List<T> unik() => toSet().toList();

  List<List<T>> batch(int ukuran) {
    final hasil = <List<T>>[];
    for (int i = 0; i < length; i += ukuran) {
      hasil.add(sublist(i, (i + ukuran).clamp(0, length)));
    }
    return hasil;
  }
}

// Extension on int
extension DurasiExtension on int {
  Duration get detik => Duration(seconds: this);
  Duration get menit => Duration(minutes: this);
  Duration get jam => Duration(hours: this);
}

// Usage
void main() {
  print('[email protected]'.isEmail);     // true
  print('Halo dunia'.capitalize());      // Halo Dunia

  final data = [1, 2, 2, 3, 3, 3];
  print(data.unik());                    // [1, 2, 3]

  print([1,2,3,4,5].batch(2));           // [[1,2],[3,4],[5]]

  await Future.delayed(2.detik);         // more expressive than Duration(seconds: 2)
}
// ANTI-PATTERN: an extension that surprisingly changes existing behavior
extension StringBerbahaya on String {
  // ✗ same name as String.length but different behavior
  // Extensions can't override existing members — this would be ignored
}

// CORRECT: extensions add, don't replace — give them clear names
extension StringHelper on String {
  int get panjangTanpaSpasi => trim().length; // ✓ clear name, no conflict
}

Static Members #

Static members are properties or methods owned by the class as a whole, not by a particular instance. Accessed through the class name, not an object.

class KonversiSuhu {
  // Static const — constants related to the class domain
  static const double absoluteZeroCelsius = -273.15;
  static const double nolAbsolutFahrenheit = -459.67;

  // Static counter — shared by all instances
  static int _jumlahKonversi = 0;
  static int get jumlahKonversi => _jumlahKonversi;

  // Static method — utilities that don't need instance state
  static double celsiusKeFahrenheit(double celsius) {
    _jumlahKonversi++;
    return celsius * 9 / 5 + 32;
  }

  static double fahrenheitKeCelsius(double fahrenheit) {
    _jumlahKonversi++;
    return (fahrenheit - 32) * 5 / 9;
  }

  static double celsiusKeKelvin(double celsius) {
    _jumlahKonversi++;
    return celsius - absoluteZeroCelsius;
  }
}

// Access without creating an instance
print(KonversiSuhu.celsiusKeFahrenheit(100)); // 212.0
print(KonversiSuhu.jumlahKonversi);           // 1
// ANTI-PATTERN: a class containing only static members without a private constructor
class Utilitas {
  // Nothing prevents calling Utilitas() — meaningless
  static void bantu() { }
}

// CORRECT: prevent instantiation with a private constructor or an abstract class
abstract class Utilitas {
  Utilitas._(); // private constructor — can't be instantiated
  static void bantu() { }
}
// Or use top-level functions if a class namespace isn't needed

Library-Based Privacy #

In Dart, there are no private, protected, or public keywords. Privacy is determined by the _ (underscore) prefix: members whose names start with _ can only be accessed from within the same file (library), not just from within the same class.

// In file: rekening_bank.dart
class RekeningBank {
  double _saldo = 0;         // private to the rekening_bank.dart library
  String _pin = '0000';      // private to the rekening_bank.dart library

  bool verifikasiPin(String pin) => pin == _pin;
}

class AuditRekening {
  // Can access _saldo because it's in the same file!
  void audit(RekeningBank rek) {
    print('Saldo: ${rek._saldo}'); // ✓ — same file
  }
}
// In file: main.dart
import 'rekening_bank.dart';

void main() {
  final rek = RekeningBank();
  // print(rek._saldo); // ✗ error: can't be accessed from another file
  print(rek.verifikasiPin('1234')); // ✓ — access through a public method
}

Summary #

  • Initializer lists (: x = expr before the body) are the place to initialize final properties with complex expressions, call super(), and place assert for prerequisite validation.
  • Named constructors communicate creation intent — Warna.merah(), Pengguna.dariJson(), Titik.kosong() are far more expressive than a single constructor with many optional parameters.
  • Factory constructors for complex creation logic: singletons, caching, or choosing the right subclass based on a condition.
  • Getters/setters provide a property-like interface with logic behind it — use getters for computed properties, setters for assignment validation.
  • copyWith is the standard pattern for “modifying” immutable objects — producing a new object with some different values without changing the original.
  • extends for “is-a” relationships that inherit implementations. implements for type contracts requiring all members to be reimplemented. with for mixing capabilities from mixins without inheritance.
  • Mixins with on restrict a mixin to be usable only by certain classes, letting the mixin access target class members safely.
  • Extension methods add capabilities to existing classes without modification and without inheritance — the best way to enrich String, List, int, and other types.
  • Dart privacy is library-based (file), not class-based — members prefixed with _ are accessible by all classes in the same file, not just the class itself.
  • Prefer composition over inheritance for code reuse — use has-a (properties) instead of is-a (extends) when the relationship isn’t genuinely “is a kind of”.

← Previous: Functions   Next: Interfaces →

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