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.

PrecedenceOperatorDescription
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 override hashCode. Two objects that are == must produce the same hashCode — this is the contract used by Set and Map for 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 a double — use ~/ for integer division, not (a / b).toInt() unless you genuinely need round/ceil/floor.
  • Prefix ++x vs postfix x++ — 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 == and hashCode together 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 — no as cast needed after a successful is check. 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 repeated addAll().
  • Operator overloading makes custom types feel natural — but the semantics must be intuitive and follow mathematical conventions. Don’t override + to do deletion.

← Previous: Data Types   Next: Conditional Logic →

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