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
}
| Aspect | extends | implements |
|---|---|---|
| Inherits implementations | ✓ | ✗ |
| Can override | ✓ (optional) | ✓ (all required) |
| Maximum count | 1 class | unlimited |
| Main purpose | Specialization | Type 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 = exprbefore the body) are the place to initializefinalproperties with complex expressions, callsuper(), and placeassertfor 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.
copyWithis the standard pattern for “modifying” immutable objects — producing a new object with some different values without changing the original.extendsfor “is-a” relationships that inherit implementations.implementsfor type contracts requiring all members to be reimplemented.withfor mixing capabilities from mixins without inheritance.- Mixins with
onrestrict 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 ofis-a(extends) when the relationship isn’t genuinely “is a kind of”.