Conditional Logic #

Conditional logic is how a program makes decisions — without it, code can only run a single linear path. Dart provides several constructs for this: flexible if-else for complex logic, the classic switch statement for discrete values, and — starting with Dart 3 — switch expressions and pattern matching, which are far more powerful than both. What separates experienced developers from beginners isn’t the ability to write if-else, but the ability to choose the right construct, structure conditions so they read top to bottom, and recognize when nested conditions should be factored into something cleaner.

if, else if, else #

The if-else construct is the most basic form of branching. Dart requires conditions to be explicitly bool — there’s no implicit truthy/falsy like in JavaScript or Python.

int skor = 82;

if (skor >= 90) {
  print('A — Excellent');
} else if (skor >= 80) {
  print('B — Good');
} else if (skor >= 70) {
  print('C — Fair');
} else if (skor >= 60) {
  print('D — Poor');
} else {
  print('E — Failed');
}

A few syntax rules to note:

// Curly braces are required in Dart — unlike C or JavaScript
// ANTI-PATTERN: without curly braces (invites bugs)
if (skor >= 90)
  print('A');   // ✗ valid syntactically, but prone to misreading
  print('B');   // this line always runs — it's not part of the if!

// CORRECT: always use curly braces
if (skor >= 90) {
  print('A');   // ✓ clear what belongs to the if
}
print('selesai'); // clearly outside the if block

The Right Condition Order #

The order of else if determines the result — more specific conditions must come first:

// ANTI-PATTERN: broad condition first — the specific one is never reached
int umur = 25;
if (umur >= 18) {
  print('dewasa');       // always enters here because 25 >= 18
} else if (umur >= 65) {
  print('lansia');       // ✗ never reached — its condition is impossible
}

// CORRECT: specific condition first, then the more general ones
if (umur >= 65) {
  print('lansia');
} else if (umur >= 18) {
  print('dewasa');
} else {
  print('minor');
}

Guard Clauses and Early Returns #

A guard clause is the technique of writing “fail fast” conditions at the top of a function — validating all prerequisites first, then running the main logic. This reduces nesting and makes the success path read linearly from top to bottom.

// ANTI-PATTERN: deep nesting that buries the main logic
double hitungDiskon(Pengguna? pengguna, Produk? produk, int qty) {
  if (pengguna != null) {
    if (produk != null) {
      if (qty > 0) {
        if (pengguna.isPremium) {
          if (produk.sedangDiskon) {
            return produk.harga * qty * 0.2; // main logic buried at level 6
          } else {
            return produk.harga * qty * 0.1;
          }
        } else {
          return 0;
        }
      } else {
        return 0;
      }
    } else {
      return 0;
    }
  } else {
    return 0;
  }
}

// CORRECT: guard clauses — validate first, then the main logic
double hitungDiskon(Pengguna? pengguna, Produk? produk, int qty) {
  // Validate prerequisites up front — return immediately if not met
  if (pengguna == null) return 0;
  if (produk == null) return 0;
  if (qty <= 0) return 0;

  // Main logic — clean, no nesting, easy to read
  if (!pengguna.isPremium) return 0;
  return produk.harga * qty * (produk.sedangDiskon ? 0.2 : 0.1);
}

The guard clause pattern is also very effective for input validation and null safety:

void prosesOrder(String? idPengguna, List<ItemKeranjang> keranjang) {
  // Guard: validate all inputs at the top
  if (idPengguna == null || idPengguna.isEmpty) {
    throw ArgumentError('User ID cannot be empty');
  }
  if (keranjang.isEmpty) {
    throw StateError('Cart cannot be empty');
  }
  if (keranjang.any((item) => item.qty <= 0)) {
    throw ArgumentError('All items must have a positive qty');
  }

  // From here, all prerequisites are met — focus on the business logic
  final total = keranjang.fold<double>(
    0,
    (acc, item) => acc + item.harga * item.qty,
  );
  simpanOrder(idPengguna, keranjang, total);
}
flowchart TD
    A[Enter function] --> B{Prerequisite 1 met?}
    B -- No --> B1[Return / Throw immediately]
    B -- Yes --> C{Prerequisite 2 met?}
    C -- No --> C1[Return / Throw immediately]
    C -- Yes --> D{Prerequisite 3 met?}
    D -- No --> D1[Return / Throw immediately]
    D -- Yes --> E[Run the main logic\\nwithout nesting]
    E --> F[Return the result]

The Classic switch Statement #

switch works by comparing one expression against several constant values. In Dart, switch supports String, int, enum, and types that implement == properly.

String hari = 'Senin';

switch (hari) {
  case 'Senin':
  case 'Selasa':
  case 'Rabu':
  case 'Kamis':
  case 'Jumat':
    print('Hari kerja');
    break;
  case 'Sabtu':
  case 'Minggu':
    print('Akhir pekan');
    break;
  default:
    print('Bukan nama hari yang valid');
}

Fall-through and break #

In Dart, every case that has code must end with break, return, throw, or continue. Fall-through (continuing to the next case without a break) is only allowed if the case is empty — like the 'Senin' through 'Jumat' example above.

int kode = 2;

switch (kode) {
  case 1:
    print('Satu');
    // ✗ error: missing break — Dart doesn't allow fall-through with code
  case 2:
    print('Dua');
    break;
}

// Allowed fall-through: empty cases
switch (kode) {
  case 1:   // ✓ fall-through from an empty case
  case 2:
    print('Satu atau Dua');
    break;
  case 3:
    print('Tiga');
    break;
}

switch with Enums #

switch is most elegant when used with enums — and Dart can warn you if an enum value isn’t handled:

enum StatusPesanan { menunggu, diproses, dikirim, selesai, dibatalkan }

void tampilkanStatus(StatusPesanan status) {
  switch (status) {
    case StatusPesanan.menunggu:
      print('Menunggu konfirmasi penjual');
      break;
    case StatusPesanan.diproses:
      print('Sedang disiapkan di gudang');
      break;
    case StatusPesanan.dikirim:
      print('Dalam perjalanan ke alamatmu');
      break;
    case StatusPesanan.selesai:
      print('Pesanan telah diterima');
      break;
    case StatusPesanan.dibatalkan:
      print('Pesanan dibatalkan');
      break;
    // Without a default — the Dart analyzer will warn if a new enum is unhandled
  }
}
When using switch with enums, avoid default if possible. Without default, the Dart analyzer warns every time a new enum value isn’t handled in the switch — a valuable safety net as the enum grows.

Switch Expressions (Dart 3) #

Dart 3 introduced the switch expression — a version of switch that produces a value rather than running statements. It’s one of the biggest features of Dart 3 and changes how we write much conditional logic.

// Classic switch statement — verbose, needs a temporary variable
String deskripsikan(StatusPesanan status) {
  String hasil;
  switch (status) {
    case StatusPesanan.menunggu:
      hasil = 'Menunggu konfirmasi';
      break;
    case StatusPesanan.diproses:
      hasil = 'Sedang diproses';
      break;
    case StatusPesanan.dikirim:
      hasil = 'Sedang dikirim';
      break;
    case StatusPesanan.selesai:
      hasil = 'Selesai';
      break;
    case StatusPesanan.dibatalkan:
      hasil = 'Dibatalkan';
      break;
  }
  return hasil;
}

// switch expression (Dart 3) — concise, directly produces a value
String deskripsikan(StatusPesanan status) => switch (status) {
  StatusPesanan.menunggu   => 'Menunggu konfirmasi',
  StatusPesanan.diproses   => 'Sedang diproses',
  StatusPesanan.dikirim    => 'Sedang dikirim',
  StatusPesanan.selesai    => 'Selesai',
  StatusPesanan.dibatalkan => 'Dibatalkan',
};
// No 'default' — a switch expression MUST be exhaustive for enums

Switch expressions can be used anywhere a value is expected:

// In an assignment
double tarif = switch (kategoriPengguna) {
  KategoriPengguna.reguler => 0.0,
  KategoriPengguna.premium => 0.10,
  KategoriPengguna.vip     => 0.20,
};

// In string interpolation
print('Your discount: ${switch (level) {
  1 => '5%',
  2 => '10%',
  3 => '15%',
  _ => '0%',  // _ is the wildcard/default
}}');

// In a direct return
int hitungPoin(String aksi) => switch (aksi) {
  'login'    => 5,
  'beli'     => 20,
  'review'   => 10,
  'referral' => 50,
  _          => 0,
};

Wildcard _ vs default #

In a switch expression, use _ (wildcard) as the fallback — it catches all values that don’t match:

// switch expression with a wildcard
String kategoriUmur(int umur) => switch (umur) {
  < 13         => 'Anak',
  >= 13 && < 18 => 'Remaja',
  >= 18 && < 60 => 'Dewasa',
  _            => 'Lansia',    // catches all other cases
};

Pattern Matching (Dart 3) #

Pattern matching is an extension of switch that lets you match the structure of data, not just values. It transforms switch from merely “choose by value” into “destructure and capture data at the same time”.

Type-Based Matching #

void gambarkan(Object bentuk) {
  switch (bentuk) {
    case Lingkaran(jariJari: var r):
      print('Lingkaran dengan jari-jari $r, luas: ${3.14 * r * r}');
    case Persegi(sisi: var s):
      print('Persegi dengan sisi $s, luas: ${s * s}');
    case PersegiPanjang(panjang: var p, lebar: var l):
      print('Persegi panjang $p×$l, luas: ${p * l}');
  }
}

Relational Patterns #

String kategorikanSuhu(double celsius) => switch (celsius) {
  < 0         => 'Beku',
  >= 0 && < 15 => 'Dingin',
  >= 15 && < 25 => 'Sejuk',
  >= 25 && < 35 => 'Hangat',
  _            => 'Panas',
};

List and Map Patterns #

Pattern matching can deconstruct collections directly:

void prosesKoordinat(List<double> coords) {
  switch (coords) {
    case []:
      print('Daftar kosong');
    case [double x]:
      print('Satu dimensi: $x');
    case [double x, double y]:
      print('Dua dimensi: ($x, $y)');
    case [double x, double y, double z]:
      print('Tiga dimensi: ($x, $y, $z)');
    case [_, _, _, ...]:
      print('Lebih dari 3 dimensi');
  }
}

// Map pattern
void bacaKonfigurasi(Map<String, dynamic> config) {
  switch (config) {
    case {'host': String host, 'port': int port}:
      print('Koneksi ke $host:$port');
    case {'host': String host}:
      print('Koneksi ke $host dengan port default');
    default:
      throw FormatException('Format konfigurasi tidak valid');
  }
}

Guard Clauses in Patterns (when) #

Patterns can be augmented with additional conditions using the when keyword:

String evaluasiNilai(int skor, bool sudahRemidi) => switch (skor) {
  >= 90                              => 'A — Sangat Baik',
  >= 80                              => 'B — Baik',
  >= 70                              => 'C — Cukup',
  >= 60 when !sudahRemidi            => 'D — Bisa Remidi',
  >= 60 when sudahRemidi             => 'D — Sudah Remidi, Tetap D',
  _                                  => 'E — Tidak Lulus',
};

// when also works in a regular switch statement
void proses(Object nilai) {
  switch (nilai) {
    case int n when n > 0:
      print('Integer positif: $n');
    case int n when n < 0:
      print('Integer negatif: $n');
    case int _:
      print('Nol');
    case String s when s.isNotEmpty:
      print('String tidak kosong: $s');
    default:
      print('Tipe atau nilai lain');
  }
}

Sealed Classes and Exhaustive Matching #

A sealed class (Dart 3) is a class that can only be extended or implemented within the same file. Combining sealed classes with switch expressions produces exhaustive branching — the compiler guarantees every possible case is handled.

// Sealed class definition — all subtypes must be in the same file
sealed class HasilOperasi {}

class Sukses extends HasilOperasi {
  final dynamic data;
  const Sukses(this.data);
}

class GagalValidasi extends HasilOperasi {
  final String pesan;
  const GagalValidasi(this.pesan);
}

class GagalJaringan extends HasilOperasi {
  final int kodeHttp;
  const GagalJaringan(this.kodeHttp);
}

// Switch expression with a sealed class — automatically exhaustive
String tanganiHasil(HasilOperasi hasil) => switch (hasil) {
  Sukses(data: var d)         => 'Berhasil: $d',
  GagalValidasi(pesan: var p) => 'Validasi gagal: $p',
  GagalJaringan(kodeHttp: var k) => 'Gagal jaringan: HTTP $k',
  // No default needed — all subtypes are handled
  // If a new subtype is added, the compiler errors right here
};
// ANTI-PATTERN: open inheritance with switch — fragile
abstract class Bentuk {}
class Lingkaran extends Bentuk { ... }
class Persegi extends Bentuk { ... }

String gambar(Bentuk b) => switch (b) {
  Lingkaran()  => 'lingkaran',
  Persegi()    => 'persegi',
  _            => throw UnimplementedError(), // ✗ needs a default because it's not exhaustive
};
// If a new Segitiga class appears, there's no compiler warning — hidden bug

// CORRECT: use a sealed class for exhaustive matching
sealed class Bentuk {}  // only Lingkaran and Persegi can exist
// The compiler ERRORS immediately if the switch doesn't handle all subtypes

When to Use Each Construct #

Choosing the right construct isn’t just a matter of taste — each construct has different strengths:

flowchart TD
    A{What needs to be decided?} --> B{Single value or\\nmultiple conditions?}
    B -- One simple condition --> C[if / ternary ?:]
    B -- Many discrete values --> D{Need to produce\\na value?}
    D -- Yes --> E{Dart 3+?}
    E -- Yes --> F[switch expression]
    E -- No --> G[switch statement\\nwith a temporary variable]
    D -- No --> H[switch statement]
    B -- Complex data structures --> I{Dart 3+?}
    I -- Yes --> J[Pattern matching\\nswitch]
    I -- No --> K[if + is + cast]
    A --> L{Validating prerequisites\\nat the top of a function?}
    L -- Yes --> M[Guard clause\\nearly return]
USE if/else when:
  ✓ Conditions involve value ranges (>= 18, < 60)
  ✓ Conditions involve several different variables
  ✓ There are only 2-3 branches
  ✓ Complex boolean conditions with && and ||

USE switch statement when:
  ✓ Comparing one variable against many discrete values
  ✓ Working with enums and needing exhaustiveness warnings
  ✓ There are fall-through cases (consecutive empty cases)

USE switch expression (Dart 3) when:
  ✓ Every branch produces a value (not running side effects)
  ✓ Working with enums or sealed classes
  ✓ You want exhaustiveness guaranteed by the compiler

USE pattern matching (Dart 3) when:
  ✓ You need to deconstruct an object while checking its type
  ✓ Working with a sealed class hierarchy
  ✓ Patterns involve List, Map, or relational conditions
  ✓ You need guard clauses (when) inside patterns

USE guard clauses / early returns when:
  ✓ A function has several prerequisites that must be met
  ✓ The main code is buried under many levels of nesting
  ✓ You want the "failure" path clearly separated from the "success" path

Conditional Anti-Patterns #

Redundant Boolean Conditions #

// ANTI-PATTERN: comparing a bool with true/false explicitly
bool aktif = cekStatus();
if (aktif == true) { ... }   // ✗ redundant
if (aktif == false) { ... }  // ✗ redundant
if (aktif != true) { ... }   // ✗ redundant

// CORRECT: use the bool directly
if (aktif) { ... }           // ✓
if (!aktif) { ... }          // ✓

Returning Boolean from If-Else #

// ANTI-PATTERN: returning true/false from if-else when the condition is already bool
bool cekEligibel(int umur, double saldo) {
  if (umur >= 18 && saldo >= 500000) {
    return true;   // ✗ unnecessary — the condition itself is already bool
  } else {
    return false;
  }
}

// CORRECT: return the boolean expression directly
bool cekEligibel(int umur, double saldo) {
  return umur >= 18 && saldo >= 500000;  // ✓
}

// Or with an arrow function
bool cekEligibel(int umur, double saldo) =>
    umur >= 18 && saldo >= 500000;

Excessive Nesting #

// ANTI-PATTERN: nesting if inside if that could be flattened
void validasiForm(String nama, String email, int umur) {
  if (nama.isNotEmpty) {
    if (email.contains('@')) {
      if (umur >= 18) {
        simpan(nama, email, umur);
      } else {
        print('Umur minimal 18 tahun');
      }
    } else {
      print('Email tidak valid');
    }
  } else {
    print('Nama tidak boleh kosong');
  }
}

// CORRECT: guard clauses invert the conditions and exit early
void validasiForm(String nama, String email, int umur) {
  if (nama.isEmpty) { print('Nama tidak boleh kosong'); return; }
  if (!email.contains('@')) { print('Email tidak valid'); return; }
  if (umur < 18) { print('Umur minimal 18 tahun'); return; }

  simpan(nama, email, umur);
}

Switch Without Default for Non-Enums #

// ANTI-PATTERN: String switch without a default — unhandled cases fail silently
void prosesKomando(String komando) {
  switch (komando) {
    case 'start':
      mulai();
      break;
    case 'stop':
      berhenti();
      break;
    // ✗ no default — the 'restart' command does nothing
  }
}

// CORRECT: always provide a default for non-enum switches
void prosesKomando(String komando) {
  switch (komando) {
    case 'start':
      mulai();
      break;
    case 'stop':
      berhenti();
      break;
    default:
      throw ArgumentError('Unknown command: $komando'); // ✓ fails clearly
  }
}

Ternary Nested Too Deep #

// ANTI-PATTERN: ternary beyond two levels — very hard to read
String hasil = a > b
    ? (a > c ? 'a' : (c > d ? 'c' : 'd'))
    : (b > c ? 'b' : (c > d ? 'c' : 'd'));

// CORRECT: a switch expression is clearer for many branches
String terbesar(int a, int b, int c, int d) {
  int maks = [a, b, c, d].reduce((curr, next) => curr > next ? curr : next);
  return switch (maks) {
    _ when maks == a => 'a',
    _ when maks == b => 'b',
    _ when maks == c => 'c',
    _                => 'd',
  };
}

// Or simpler with different logic:
String terbesar(int a, int b, int c, int d) {
  final nilai = {'a': a, 'b': b, 'c': c, 'd': d};
  return nilai.entries.reduce((e1, e2) => e1.value > e2.value ? e1 : e2).key;
}

Summary #

  • if-else is the go-to for conditions involving value ranges, multiple variables, or complex boolean logic. Always use curly braces even for a single statement.
  • Guard clauses and early returns dramatically reduce nesting — validate all prerequisites at the top, then run the main logic below. Code reads top to bottom without deep nesting.
  • switch statements are best for discrete values and enums. Avoid default when using enums so the compiler can warn about unhandled values.
  • Switch expressions (Dart 3) are switch that produce a value — concise, exhaustive for enums, and usable directly inside any expression. Use _ as the wildcard replacement for default.
  • Pattern matching (Dart 3) lets you deconstruct and match data structures at once — types, Lists, Maps, relational patterns, all combinable with when for extra guards.
  • Sealed classes + switch expressions produce branching that the compiler guarantees to be exhaustive — if a new subtype is added, every switch that doesn’t handle it errors immediately.
  • Avoid comparing bool with == true or == false, returning true/false from if-else when the conditional expression itself is already bool, and nesting ternaries more than two levels.
  • Condition order matters — more specific conditions must always come first; more general ones below, so they don’t “cover up” cases that should be handled earlier.

← Previous: Operators   Next: Loops →

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