Operators #
Operators are the heart of every expression — without understanding how they work, code that looks correct can produce surprising results. Dart inherits most operators from the C family, but adds several that are its exclusive strengths: null-aware operators (??, ?., ??=), cascade (.., ?..), and spread (...). What sets this article apart from a mere list of symbols: every operator group is explained from the angle of non-obvious behavior — precedence, short-circuit evaluation, prefix vs postfix differences, and when a particular operator should actually be avoided.
Operator Precedence #
Before discussing operators one by one, it’s important to understand that operators have precedence — the evaluation priority order when more than one operator appears in a single expression. The higher the precedence, the earlier it’s evaluated.
| Precedence | Operator | Description |
|---|---|---|
| 16 (highest) | () [] ?. ! | Access, index, null-aware |
| 15 | ++ -- (postfix) | Increment/decrement after |
| 14 | ! ~ - ++ -- (prefix) | Unary, NOT, negation, before |
| 13 | * / ~/ % | Multiplication and division |
| 12 | + - | Addition and subtraction |
| 11 | << >> >>> | Bit shift |
| 10 | & | Bitwise AND |
| 9 | ^ | Bitwise XOR |
| 8 | | | Bitwise OR |
| 7 | >= > <= < as is is! | Comparison and type tests |
| 6 | == != | Equality |
| 5 | && | Logical AND |
| 4 | || | Logical OR |
| 3 | ?? | Null-aware fallback |
| 2 | ?: | Conditional ternary |
| 1 (lowest) | = += -= etc, ??= | Assignment |
// Precedence examples that often confuse
int hasil = 2 + 3 * 4; // 14, not 20 — * is higher than +
bool cek = 5 > 3 && 2 < 4; // true — > and < are evaluated before &&
// Use parentheses for clarity when precedence isn't obvious
bool valid = (umur >= 18) && (saldo > 0) || darurat;
// vs
bool valid = (umur >= 18) && ((saldo > 0) || darurat); // different meaning!
// ANTI-PATTERN: relying on precedence for complex expressions without parentheses
bool lolos = skor >= 70 && !diskualifikasi || hasilBanding;
// Is this: (skor >= 70 && !diskualifikasi) || hasilBanding
// Or: skor >= 70 && (!diskualifikasi || hasilBanding)?
// CORRECT: explicit parentheses for every meaningful sub-expression
bool lolos = (skor >= 70 && !diskualifikasi) || hasilBanding;
Arithmetic Operators #
Arithmetic operators work on numeric types (int, double, num). One thing that sets Dart apart from many other languages: the / operator always produces a double, never an int, even when both operands are int.
int a = 17;
int b = 5;
print(a + b); // 22
print(a - b); // 12
print(a * b); // 85
print(a / b); // 3.4 — always double!
print(a ~/ b); // 3 — integer division (floor division)
print(a % b); // 2 — remainder (modulo)
print(-a); // -17 — unary negation
// ANTI-PATTERN: using / and expecting an int
int total = 100;
int bagian = total / 4; // ✗ error: double can't be assigned to int
int bagian = total / 4; // ✗ compile error
// CORRECT: use ~/ for integer division
int bagian = total ~/ 4; // ✓ 25
// or convert explicitly if you need double then int
int bagian = (total / 4).toInt(); // ✓ 25 (truncate)
int bagian = (total / 4).round(); // ✓ 25 (round to nearest)
Increment and Decrement: Prefix vs Postfix #
This is one of the most subtle sources of bugs in all programming languages. The difference is in when the value is returned relative to the operation:
int x = 5;
// Prefix — increment FIRST, then return the new value
print(++x); // 6 — x becomes 6, then 6 is printed
print(x); // 6
// Postfix — return the OLD value, then increment
x = 5; // reset
print(x++); // 5 — prints 5 (old value), then x becomes 6
print(x); // 6
// ANTI-PATTERN: increment inside an expression that depends on its value
int i = 0;
int hasil = i++ + i++; // ✗ result is unclear — depends on evaluation order
// Avoid placing ++ or -- in complex expressions
// CORRECT: do the increment separately from the expression
int i = 0;
int hasil = i + (i + 1); // clear intent
i += 2;
Modulo and Negative Special Cases #
Modulo (%) in Dart follows the sign of the left operand (unlike some languages that follow the sign of the right operand):
print(7 % 3); // 1 — positive % positive = positive
print(-7 % 3); // 2 — Dart: sign follows the RIGHT operand (divisor)
print(7 % -3); // -2 — sign follows the RIGHT operand
print(-7 % -3); // -1 — both negative, result negative
// If you always want positive (e.g. for circular indexing):
int indeksSirkular(int n, int panjang) => ((n % panjang) + panjang) % panjang;
print(indeksSirkular(-1, 5)); // 4 — always in [0, panjang-1]
Comparison Operators #
Comparison operators always produce a bool. In Dart, == compares values — not memory references (unlike Java where == compares references for objects).
int a = 10;
int b = 10;
print(a == b); // true — equal values
print(a != b); // false
print(a > b); // false
print(a < b); // false
print(a >= b); // true
print(a <= b); // true
== on Objects: Value vs Reference
#
// String — == compares values (content)
String s1 = 'halo';
String s2 = 'halo';
print(s1 == s2); // true — same content
// List — == compares references, NOT content
List<int> l1 = [1, 2, 3];
List<int> l2 = [1, 2, 3];
print(l1 == l2); // false — two different objects in memory
print(identical(l1, l2)); // false — different references
// To compare list contents, use the collection package or listEquals
import 'package:collection/collection.dart';
print(const ListEquality().equals(l1, l2)); // true
// ANTI-PATTERN: using == to compare Lists or Maps
List<String> pilihan = ['a', 'b', 'c'];
List<String> jawaban = ['a', 'b', 'c'];
if (pilihan == jawaban) { // ✗ always false — compares references
print('Same');
}
// CORRECT: use listEquals (from Flutter) or ListEquality (from the collection package)
import 'package:flutter/foundation.dart';
if (listEquals(pilihan, jawaban)) { // ✓
print('Same');
}
Overriding == in Custom Classes
#
If your class represents a value object (an object whose identity is determined by its value, not its reference), you need to override == and hashCode together:
class Koordinat {
final double lat;
final double lng;
const Koordinat(this.lat, this.lng);
// Must override hashCode if overriding ==
@override
int get hashCode => Object.hash(lat, lng);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Koordinat && other.lat == lat && other.lng == lng;
}
}
void main() {
final a = Koordinat(-6.2, 106.8);
final b = Koordinat(-6.2, 106.8);
print(a == b); // true — after the override
print({a, b}.length); // 1 — Set considers them equal
}
If you override==, you must also overridehashCode. Two objects that are==must produce the samehashCode— this is the contract used bySetandMapfor efficient lookups. Breaking this contract causes bugs that are extremely hard to trace.
Logical Operators #
Logical operators work on bool values and return bool. Dart evaluates them in short-circuit fashion — the right operand is only evaluated if the result can’t already be determined from the left operand.
bool a = true;
bool b = false;
print(a && b); // false — AND: both must be true
print(a || b); // true — OR: at least one true
print(!a); // false — NOT: invert the value
print(a ^ b); // true — XOR: exactly one true
Short-Circuit Evaluation #
Short-circuit behavior isn’t just an optimization — it’s a feature that can and often is used for safety:
// && short-circuit: if the left is false, the right is NOT evaluated
String? nama;
// Safe — if nama is null, nama.isNotEmpty is never called
if (nama != null && nama.isNotEmpty) {
print(nama.toUpperCase());
}
// || short-circuit: if the left is true, the right is NOT evaluated
bool izin = sudahLogin || modeDemo();
// modeDemo() is only called if sudahLogin == false
// Useful if modeDemo() is expensive or has side effects
// An elegant usage example
List<String>? items;
int panjang = items?.length ?? 0;
bool adaItem = items != null && items.isNotEmpty;
// ANTI-PATTERN: relying on short-circuit for non-obvious control flow
bool sukses = simpanData() || tampilkanError();
// ✗ tampilkanError() is only called if simpanData() fails — hidden intent
// CORRECT: use explicit if-else for control flow
bool sukses = simpanData();
if (!sukses) tampilkanError();
Assignment Operators #
Dart provides compound assignment operators for all arithmetic and bitwise operators, plus ??= which is specific to null safety.
int n = 10;
n += 5; // n = n + 5 → 15
n -= 3; // n = n - 3 → 12
n *= 2; // n = n * 2 → 24
n ~/= 5; // n = n ~/ 5 → 4 (integer division)
n %= 3; // n = n % 3 → 1
n <<= 2; // n = n << 2 → 4 (left shift)
n >>= 1; // n = n >> 1 → 2 (right shift)
n &= 3; // n = n & 3 → 2 (bitwise AND)
n |= 5; // n = n | 5 → 7 (bitwise OR)
n ^= 3; // n = n ^ 3 → 4 (bitwise XOR)
// Specifically for nullables
String? pesan;
pesan ??= 'default'; // assign 'default' only if pesan is still null
print(pesan); // 'default'
pesan ??= 'lain'; // does nothing — pesan is already 'default'
print(pesan); // 'default'
// ANTI-PATTERN: rewriting variables unnecessarily
jumlah = jumlah + tambahan; // verbose
total = total - diskon; // verbose
// CORRECT: compound operators are more concise and expressive
jumlah += tambahan;
total -= diskon;
Bitwise Operators #
Bitwise operators work directly on the binary representation of int. Very useful for flag manipulation, encoding, masking, and high-performance operations.
int a = 0b1010; // 10 in decimal
int b = 0b1100; // 12 in decimal
print('a & b = ${(a & b).toRadixString(2).padLeft(4,'0')}'); // 1000 = 8
print('a | b = ${(a | b).toRadixString(2).padLeft(4,'0')}'); // 1110 = 14
print('a ^ b = ${(a ^ b).toRadixString(2).padLeft(4,'0')}'); // 0110 = 6
print('~a = ${(~a)}'); // -11 (two's complement)
print('a << 1 = ${(a << 1).toRadixString(2)}'); // 10100 = 20
print('a >> 1 = ${(a >> 1).toRadixString(2)}'); // 0101 = 5
Common Pattern: Bit Flags #
Bitwise is most often used to store several booleans in a single integer using bits as flags:
// Define flags as bit constants
class Izin {
static const int baca = 1 << 0; // 0001 = 1
static const int tulis = 1 << 1; // 0010 = 2
static const int hapus = 1 << 2; // 0100 = 4
static const int admin = 1 << 3; // 1000 = 8
}
// Combine flags with |
int izinEditor = Izin.baca | Izin.tulis; // 0011 = 3
int izinAdmin = Izin.baca | Izin.tulis | Izin.hapus | Izin.admin; // 1111 = 15
// Check flags with &
bool bisaBaca = (izinEditor & Izin.baca) != 0; // true
bool bisaHapus = (izinEditor & Izin.hapus) != 0; // false
// Add a flag with |=
izinEditor |= Izin.hapus; // add delete permission
// Remove a flag with &= and ~
izinEditor &= ~Izin.tulis; // remove write permission
Shifts for Fast Multiplication/Division #
// Left shift = multiply by 2^n
int x = 5;
print(x << 1); // 10 — same as x * 2
print(x << 2); // 20 — same as x * 4
print(x << 3); // 40 — same as x * 8
// Right shift = divide by 2^n (floor division)
print(x >> 1); // 2 — same as x ~/ 2
print(x >> 2); // 1 — same as x ~/ 4
Conditional Operators #
Dart provides two concise conditional operators often used as replacements for simple if-else.
Ternary: kondisi ? nilaijika : nilaiJikaTidak
#
int umur = 20;
String status = umur >= 18 ? 'dewasa' : 'minor';
print(status); // dewasa
// Ternary can be nested, but watch readability
String kategori = umur < 13 ? 'anak'
: umur < 18 ? 'remaja'
: umur < 60 ? 'dewasa'
: 'lansia';
// ANTI-PATTERN: ternary nesting that's too deep
String hasil = a > b ? (a > c ? 'a terbesar' : 'c terbesar')
: (b > c ? 'b terbesar' : 'c terbesar');
// ✗ hard to read — more than two levels of nesting
// CORRECT: use plain if-else or a separate function
String tentukan(int a, int b, int c) {
if (a > b && a > c) return 'a terbesar';
if (b > c) return 'b terbesar';
return 'c terbesar';
}
if-null: ekspresi ?? fallback
#
The ?? operator returns the left operand if it’s not null, or the right operand if null. It’s Dart’s most idiomatic null-check replacement:
String? nama;
// With a ternary (verbose)
String tampil = nama != null ? nama : 'Tamu';
// With ?? (more concise and idiomatic Dart)
String tampil = nama ?? 'Tamu';
// Can be chained
String hasil = ambilDariCache() ?? ambilDariDatabase() ?? 'default';
// Try the cache first, if null try the database, if still null use the default
Null-Aware Operators #
This is the operator group that most sets Dart apart from other languages and has the biggest impact on handling null safety elegantly.
class Pengguna {
final String nama;
final Alamat? alamat; // nullable
const Pengguna({required this.nama, this.alamat});
}
class Alamat {
final String kota;
final String? kodePos; // also nullable
const Alamat({required this.kota, this.kodePos});
}
Pengguna? pengguna = ambilPengguna(); // can be null
// ?. — safe navigation: access a property/method only if the object isn't null
String? namaKota = pengguna?.alamat?.kota;
// If pengguna is null → null
// If alamat is null → null
// If neither is null → the city value
// ?? — fallback: provide a default if null
String kotaTampil = pengguna?.alamat?.kota ?? 'Unknown city';
// ??= — assign if null
pengguna ??= Pengguna(nama: 'Tamu'); // assign if pengguna is still null
// ?.. — null-aware cascade
pengguna?..kirimNotifikasi()..simpanLog();
// If pengguna is null, neither method is called at all
// ANTI-PATTERN: verbose multi-level manual null checks
String kotaTampil;
if (pengguna != null) {
if (pengguna.alamat != null) {
if (pengguna.alamat!.kota.isNotEmpty) {
kotaTampil = pengguna.alamat!.kota;
} else {
kotaTampil = 'Tidak diketahui';
}
} else {
kotaTampil = 'Tidak diketahui';
}
} else {
kotaTampil = 'Tidak diketahui';
}
// CORRECT: null-aware operators summarize all the conditions above
String kotaTampil = pengguna?.alamat?.kota.isNotEmpty == true
? pengguna!.alamat!.kota
: 'Tidak diketahui';
// Or even simpler:
String kotaTampil = (pengguna?.alamat?.kota ?? '').isEmpty
? 'Tidak diketahui'
: pengguna!.alamat!.kota;
Type Test Operators: is, is!, and as
#
These operators work with Dart’s type system to check and convert types safely.
Object nilai = 42;
// is — check the type, produces a bool
print(nilai is int); // true
print(nilai is String); // false
print(nilai is num); // true — int is a subtype of num
// is! — negation of is
print(nilai is! String); // true
// as — force-cast to a specific type
// If the type doesn't match, throws CastError at runtime
String teks = nilai as String; // ✗ CastError — nilai is an int, not a String
Automatic Type Promotion #
The best feature of is: after a successful check, Dart automatically treats the variable as the checked type — no explicit cast needed:
void prosesNilai(Object nilai) {
if (nilai is String) {
// Inside this block, Dart knows 'nilai' is a String
// All String methods are directly available without a cast!
print(nilai.toUpperCase()); // ✓
print(nilai.length); // ✓
print(nilai.split(',')); // ✓
}
if (nilai is int) {
print(nilai * 2); // ✓ directly int
print(nilai.isEven); // ✓
}
if (nilai is List<String>) {
print(nilai.first); // ✓ directly List<String>
nilai.sort(); // ✓
}
}
// ANTI-PATTERN: casting directly with as without a check
void proses(Object data) {
String teks = data as String; // ✗ crashes if data isn't a String
print(teks.length);
}
// CORRECT: check with is first — type promotion eliminates the need for as
void proses(Object data) {
if (data is String) {
print(data.length); // ✓ type-safe, no as needed
} else {
throw ArgumentError('Expected String, got: ${data.runtimeType}');
}
}
Cascade Operator: .. and ?..
#
Cascade lets you call multiple methods or access multiple properties on the same object in a single chained expression. Cascade doesn’t return the last value — it always returns the original object.
// Without cascade — verbose, repeats the variable name
var buffer = StringBuffer();
buffer.write('Halo');
buffer.write(', ');
buffer.write('Dart');
buffer.writeln('!');
print(buffer.toString()); // Halo, Dart!
// With cascade — concise
var buffer = StringBuffer()
..write('Halo')
..write(', ')
..write('Dart')
..writeln('!');
print(buffer.toString()); // Halo, Dart!
Cascade is very useful for setting up objects that need a lot of configuration:
class KonfigurasiServer {
late String host;
late int port;
late bool ssl;
late Duration timeout;
late int maksKoneksi;
void aturHost(String h) => host = h;
void aturPort(int p) => port = p;
void aktifkanSsl() => ssl = true;
void aturTimeout(Duration d) => timeout = d;
void aturMaksKoneksi(int n) => maksKoneksi = n;
}
// Without cascade
var config = KonfigurasiServer();
config.aturHost('localhost');
config.aturPort(8080);
config.aktifkanSsl();
config.aturTimeout(Duration(seconds: 30));
config.aturMaksKoneksi(100);
// With cascade — one cohesive expression
var config = KonfigurasiServer()
..aturHost('localhost')
..aturPort(8080)
..aktifkanSsl()
..aturTimeout(Duration(seconds: 30))
..aturMaksKoneksi(100);
?.. is the null-aware version of cascade — the entire chain is skipped if the object is null:
Pengguna? pengguna = cariPengguna(id);
// ?.. — does nothing if pengguna is null
pengguna
?..kirimNotifikasi('Login successful')
..simpanLog(aksi: 'login')
..perbaruiWaktuAktif();
Spread Operator: ... and ...?
#
Spread lets you insert all elements of one collection into another — a very concise way to combine or build collections from their parts.
List<int> a = [1, 2, 3];
List<int> b = [4, 5, 6];
// Combining lists
List<int> gabung = [...a, ...b]; // [1, 2, 3, 4, 5, 6]
// Insert in the middle
List<int> dengan7 = [...a, 7, ...b]; // [1, 2, 3, 7, 4, 5, 6]
// Null-aware spread — skipped if null
List<int>? opsional = null;
List<int> aman = [...a, ...?opsional, ...b]; // [1, 2, 3, 4, 5, 6]
// Spread also works for Set and Map
Set<String> s1 = {'a', 'b'};
Set<String> s2 = {'c', 'd'};
Set<String> gabungSet = {...s1, ...s2}; // {'a', 'b', 'c', 'd'}
Map<String, int> m1 = {'a': 1, 'b': 2};
Map<String, int> m2 = {'c': 3, 'd': 4};
Map<String, int> gabungMap = {...m1, ...m2}; // {'a': 1, 'b': 2, 'c': 3, 'd': 4}
// ANTI-PATTERN: combining lists with verbose addAll
List<String> hasil = [];
hasil.addAll(listA);
hasil.addAll(listB);
hasil.add('tambahan');
hasil.addAll(listC);
// CORRECT: spread directly in the literal
List<String> hasil = [...listA, ...listB, 'tambahan', ...listC];
Defining Operators in Custom Classes #
Dart lets you redefine (override) most operators for classes you create — this is called operator overloading. It makes code working with custom types feel natural:
class Vektor {
final double x;
final double y;
const Vektor(this.x, this.y);
// Override the + operator
Vektor operator +(Vektor lain) => Vektor(x + lain.x, y + lain.y);
// Override the - operator
Vektor operator -(Vektor lain) => Vektor(x - lain.x, y - lain.y);
// Override the * operator (scalar multiplication)
Vektor operator *(double skalar) => Vektor(x * skalar, y * skalar);
// Override the == operator (must also override hashCode)
@override
bool operator ==(Object other) =>
other is Vektor && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
// Override the [] operator for component access
double operator [](int index) {
if (index == 0) return x;
if (index == 1) return y;
throw RangeError('Index $index out of range [0, 1]');
}
@override
String toString() => 'Vektor($x, $y)';
}
void main() {
final v1 = Vektor(1, 2);
final v2 = Vektor(3, 4);
print(v1 + v2); // Vektor(4.0, 6.0)
print(v2 - v1); // Vektor(2.0, 2.0)
print(v1 * 3); // Vektor(3.0, 6.0)
print(v1 == Vektor(1, 2)); // true
print(v1[0]); // 1.0
}
Operators that can be overridden in Dart:
< > <= >=
- + / ~/ * %
| ^ &
<< >> >>>
[] []=
~ ==
// ANTI-PATTERN: operator overloading that surprises with its semantics
class Daftar {
List<String> _items = [];
// ✗ a + that removes items — violates reader expectations
Daftar operator +(String item) {
_items.remove(item); // surprising! + but removing?
return this;
}
}
// CORRECT: operator overloading must be intuitive and follow mathematical conventions
class Daftar {
final List<String> _items;
const Daftar(this._items);
// + that adds — as expected
Daftar operator +(Daftar lain) => Daftar([..._items, ...lain._items]);
}
Summary #
- Operator precedence determines evaluation order — use explicit parentheses for complex expressions so the code’s intent is unambiguous.
/always produces adouble— use~/for integer division, not(a / b).toInt()unless you genuinely need round/ceil/floor.- Prefix
++xvs postfixx++— prefix increments then returns the new value; postfix returns the old value then increments. Avoid both inside expressions that depend on their value.==compares values, not references, for all types in Dart — but List and Map don’t implement content comparison by default. Override==andhashCodetogether for custom value objects.- Short-circuit
&&and||— the right operand isn’t evaluated if the result is already determined by the left operand. Use this for safe navigation, but don’t hide important control flow inside it.- Null-aware operators (
??,?.,??=,?..) are Dart’s idiomatic way to handle nullables without multi-level manual null checks.- Type promotion after
is— noascast needed after a successfulischeck. Dart automatically treats the variable as the verified type inside that block.- Cascade
..returns the original object, not the method’s result — ideal for setting up objects with lots of configuration.- Spread
...is the most concise way to combine or build collections from their parts — more expressive than repeatedaddAll().- Operator overloading makes custom types feel natural — but the semantics must be intuitive and follow mathematical conventions. Don’t override
+to do deletion.