Variables #
Variables are where you store data in memory while a program runs. In Dart, how you declare a variable isn’t just a style choice — it determines whether the value can change, whether it can be null, and whether the compiler can verify correctness before the program runs. Dart provides four main keywords for variable declaration (var, explicit types, final, const), plus the late modifier for special deferred-initialization cases. Understanding when to use each is the key to writing Dart code that’s safe, expressive, and maintainable.
How Dart Stores Variables #
Before getting into the syntax, there’s one fundamental concept to understand: in Dart, all variables store references to objects, not the values themselves. Even primitive types like int and bool are objects in Dart — there’s no distinction between primitive types and object types like in Java.
int angka = 42;
// The variable 'angka' stores a reference to an int object with value 42
// not the value 42 directly on the stack like in C/Java
The implication: all variables can theoretically hold null — null is a reference that points to no object at all. This is why Dart needs an explicit null safety system to guarantee program safety at compile time, not just at runtime.
flowchart LR
subgraph Stack
V1["var nama"]
V2["int umur"]
V3["String? kota"]
end
subgraph Heap
O1["'Budi'"]
O2["42"]
O3["null"]
end
V1 --> O1
V2 --> O2
V3 --> O3Declaring with var
#
The var keyword is the most concise way to declare a variable when its type is already obvious from the given value. The Dart compiler infers the type automatically — once the type is inferred, the variable can only hold values of that same type.
var nama = 'Budi'; // Dart infers: String
var umur = 25; // Dart infers: int
var tinggi = 1.75; // Dart infers: double
var aktif = true; // Dart infers: bool
var skor = [90, 85, 92]; // Dart infers: List<int>
// Once the type is inferred, it can't be changed to another type
nama = 'Siti'; // ✓ String to String
nama = 42; // ✗ error: int can't be assigned to String
var is not dynamic typing — Dart remains statically typed. The difference from explicit types is only in source-code readability, not runtime behavior.
// ANTI-PATTERN: using var when the type isn't obvious from the value
var hasil = prosesData(); // what type does prosesData() return?
var x = a * b + c / d; // what type does this expression produce?
var config = ambilKonfigurasi(); // Map? Object? String? unclear
// CORRECT: use explicit types when the type isn't obvious
double hasilHitung = a * b + c / d;
Map<String, dynamic> config = ambilKonfigurasi();
List<Produk> daftarProduk = repository.ambilSemua();
A practical guideline: use var when the initial value is written directly on the same line and the type is clear to the reader at a glance. Use explicit types for everything else — especially declarations without an initial value, function parameters, and return types.
Declaring with Explicit Types #
Writing the type explicitly provides inline documentation that can’t go stale like comments — the compiler will immediately complain if there’s a mismatch.
String nama = 'Budi';
int umur = 25;
double gaji = 8_500_000.0; // underscore as a thousands separator — valid in Dart
bool sudahVerifikasi = false;
// Collection types with generics
List<String> kota = ['Jakarta', 'Bandung', 'Surabaya'];
Map<String, int> skor = {'matematika': 90, 'fisika': 85};
Set<String> tag = {'dart', 'flutter', 'mobile'};
Explicit types are required in several contexts:
// 1. Declarations without an initial value (can't use var)
String namaLengkap; // ✓ explicit type
// var namaLengkap; // ✗ var without an initial value: type becomes dynamic
// 2. Function parameters
void sapa(String nama, int umur) { ... } // ✓
// void sapa(var nama, var umur) { ... } // ✗ not valid
// 3. Function return types
String formatNama(String depan, String belakang) { // ✓
return '$depan $belakang';
}
// 4. Class properties
class Pengguna {
String nama; // ✓ explicit type on class properties
int umur;
Pengguna(this.nama, this.umur);
}
final — Cannot Be Changed After Being Set
#
final declares a variable that can only have its value set once. After initialization, the value can’t be replaced — but if the value is a mutable object (like a List), the object’s contents can still be modified.
final String nama = 'Budi';
// nama = 'Siti'; // ✗ error: a final variable can't be reassigned
final umur = 25; // type inferred: int
// Initialization can be deferred to runtime (unlike const)
final DateTime sekarang = DateTime.now(); // ✓ runtime value
final List<String> kota = ['Jakarta', 'Bandung'];
// The final variable itself can't be replaced, but the list's contents can
kota.add('Surabaya'); // ✓ the list's contents can be modified
kota = ['Medan']; // ✗ error: a final reference can't be replaced
When final Is Better Than var
#
As a general principle: declare all variables as final unless you genuinely need to change their value. This isn’t just style — it makes the code’s intent clearer and prevents accidental changes.
// ANTI-PATTERN: everything as var even though some are never changed
var nama = 'Budi'; // nama is never changed in the rest of the code
var umur = 25; // umur is also never changed
var counter = 0; // counter changes inside a loop — var is fine here
// CORRECT: final for things that don't change, var/explicit type for things that do
final String nama = 'Budi';
final int umur = 25;
int counter = 0; // this genuinely needs to change
final is also very common on class properties to guarantee immutability after the constructor finishes:
class Produk {
final String id; // must not change after creation
final String nama;
double harga; // price can change (e.g. during promotions)
int stok; // stock changes with transactions
Produk({
required this.id,
required this.nama,
required this.harga,
this.stok = 0,
});
}
const — Compile-Time Constants
#
const is a stricter version of final. Its value must be known at compile time — not runtime. That means a const value can’t depend on user input, function results, or execution time.
const double pi = 3.14159265358979;
const int maksimalRetry = 3;
const String versiApp = '1.0.0';
// const expressions are also valid — as long as all components are const
const double duaPi = 2 * pi; // ✓ all components are already known
const int batasMaksimal = 10 * 10; // ✓ arithmetic literals
Values that depend on runtime cannot be const:
// ANTI-PATTERN: trying to make a runtime value const
const DateTime sekarang = DateTime.now(); // ✗ error: DateTime.now() is a runtime value
const String input = stdin.readLineSync()!; // ✗ error: user input is runtime
// CORRECT: use final for values that can only be determined at runtime
final DateTime sekarang = DateTime.now(); // ✓
final String input = stdin.readLineSync()!; // ✓
const on Objects and Collections
#
const can be applied not just to variables, but directly to values — including lists, maps, and class instances that support it:
// const objects — one instance shared across the entire program (canonical instance)
const listKonstanta = [1, 2, 3]; // a fully immutable List<int>
const mapKonstanta = {'a': 1}; // a fully immutable Map
// Unlike a final list where only the reference can't be replaced
final listFinal = [1, 2, 3];
listFinal.add(4); // ✓ the contents of a final list can be modified
listKonstanta.add(4); // ✗ error: a const list can't be modified at all
// A class that supports a const constructor
class Warna {
final int r, g, b;
const Warna(this.r, this.g, this.b);
}
// const instances — created once at compile time, not recreated at runtime
const Warna merah = Warna(255, 0, 0);
const Warna hijau = Warna(0, 255, 0);
// Reference identity is guaranteed to be the same for identical const values
const a = Warna(255, 0, 0);
const b = Warna(255, 0, 0);
print(identical(a, b)); // true — the same single object
Using const in Flutter matters a lot for performance — const widgets aren’t rebuilt when their parent rebuilds because Dart guarantees their instances are identical.
Comparing var, final, and const
#
| Aspect | var / explicit type | final | const |
|---|---|---|---|
| Can be changed | ✓ | ✗ after being set | ✗ |
| Initialization time | Runtime | Runtime | Compile time |
| Type inferred | ✓ (with var) | ✓ | ✓ |
| Runtime value | ✓ | ✓ | ✗ |
| Mutable object contents | ✓ | ✓ | ✗ |
| Canonical instance | ✗ | ✗ | ✓ |
flowchart TD
A{Does the value need to\\nchange after being set?} -- Yes --> B[Use var or\\nan explicit type]
A -- No --> C{Is the value already known\\nat compile time?}
C -- Yes --> D[Use const]
C -- No --> E[Use final]Null Safety and Nullable Variables #
Since Dart 2.12, all variables are non-nullable by default — the compiler guarantees that non-nullable variables never hold null at runtime. To allow null, you must explicitly add ? after the type.
// Non-nullable — guaranteed never null by the compiler
String nama = 'Budi';
int umur = 25;
// nama = null; // ✗ compile error
// Nullable — can hold null, must be handled before use
String? alamat; // initial value: null
int? nilaiUjian; // initial value: null
alamat = 'Jl. Merdeka 1';
nilaiUjian = null; // ✓ valid for nullable
Accessing Nullable Values Safely #
Before using a nullable value, Dart requires you to handle the possibility of null explicitly. There are several ways:
String? kota;
// 1. ?? operator — provide a default value if null
String tampilan = kota ?? 'Unknown city';
// 2. ?. operator — access a property/method only if not null
int? panjangKota = kota?.length; // the result is int? not int
// 3. if null check — Dart performs type promotion after the check
if (kota != null) {
// Inside this block, Dart knows 'kota' is definitely String (not String?)
print(kota.length); // ✓ safe, no ?. needed
print(kota.toUpperCase());
}
// 4. Null assertion operator ! — force non-null (use with caution)
print(kota!.length); // throws if kota turns out to be null at runtime
// ANTI-PATTERN: using ! carelessly without guaranteeing the value
String? input = ambilInputPengguna();
print(input!.length); // ✗ will crash if the user didn't enter anything
// CORRECT: handle null explicitly
String? input = ambilInputPengguna();
if (input == null || input.isEmpty) {
print('Input cannot be empty');
return;
}
// Here, Dart knows input is definitely non-null
print(input.length); // ✓ safe
The ??= Operator — Assign If Null
#
String? nama;
nama ??= 'Guest'; // assign 'Guest' only if nama is still null
print(nama); // Guest
nama ??= 'Admin'; // does nothing because nama is already 'Guest'
print(nama); // Guest
late — Deferred Initialization
#
The late keyword lets you declare a non-nullable variable without providing an initial value immediately — with the promise that the value will be filled in before the variable is first accessed. If this promise is broken (the variable is accessed before being filled), Dart throws a LateInitializationError at runtime.
late String koneksiDatabase;
void inisialisasiApp() {
koneksiDatabase = 'postgresql://localhost/mydb';
}
void main() {
inisialisasiApp();
print(koneksiDatabase); // ✓ safe, already initialized
}
When late Is Necessary
#
There are three common scenarios where late is genuinely needed:
1. Variables that can’t be initialized at the declaration but aren’t nullable
class FormPendaftaran extends StatefulWidget {
// This controller must be initialized in initState(), not the constructor
late TextEditingController namaController;
late TextEditingController emailController;
@override
void initState() {
super.initState();
namaController = TextEditingController();
emailController = TextEditingController();
}
@override
void dispose() {
namaController.dispose();
emailController.dispose();
super.dispose();
}
}
2. Lazy initialization — expensive initialization that only runs if actually needed
class LaporanBulanan {
// This data is expensive to compute — only computed once on first access
late final List<Transaksi> _transaksiTerfilter = _hitungTransaksi();
List<Transaksi> _hitungTransaksi() {
// Heavy operation: database query, filter, sort
print('Calculating transactions...'); // only appears once
return repository.ambilSemua().where((t) => t.bulan == bulanIni).toList();
}
}
3. late final — initialized once but after the constructor
class KoneksiDatabase {
late final String connectionString;
void hubungkan(String host, String dbName) {
connectionString = 'postgresql://$host/$dbName'; // can only be set once
}
}
// ANTI-PATTERN: using late to dodge null safety without a clear reason
class Pengguna {
late String nama; // ✗ better to use nullable String? or required in the constructor
late int umur; // ✗ invites hard-to-debug LateInitializationErrors
}
// CORRECT: use null safety explicitly
class Pengguna {
String? nama; // nullable if it genuinely can be absent
int umur;
Pengguna({required this.umur, this.nama});
}
lateis the exception, not the rule. Use it only when you truly can’t provide a value at the point of declaration. Too manylates in one class is a signal that the class design needs revisiting.
Variable Scope #
Scope determines where a variable can be accessed. Dart uses lexical scoping — a variable can be accessed from inside the block where it’s declared and all blocks nested inside it, but not from outside.
// Top-level variable — accessible from the entire file
int hitungGlobal = 0;
class Kalkulator {
// Instance variable — accessible from all methods in this class
double _memori = 0;
double tambah(double a, double b) {
// Local variable — only exists inside this function
double hasil = a + b;
if (hasil > 1000) {
// Block variable — only exists inside this if block
String pesan = 'Very large result: $hasil';
print(pesan);
}
// print(pesan); // ✗ error: 'pesan' is not in scope here
return hasil;
}
}
Variable Shadowing #
Dart allows a variable in an inner scope to have the same name as a variable in an outer scope — this is called shadowing. It’s legal but often a source of confusion:
String nama = 'Global';
void contohShadowing() {
String nama = 'Local'; // hides the global variable 'nama'
print(nama); // Local
{
String nama = 'Block'; // hides 'Local'
print(nama); // Block
}
print(nama); // Local — back to the function scope
}
// ANTI-PATTERN: accidental shadowing causes subtle bugs
class PenghitungSkor {
int skor = 0;
void tambahPoin(int skor) { // the 'skor' parameter hides the 'skor' field
skor += 10; // this changes the parameter, NOT the field!
// this.skor is unchanged — a hard-to-trace bug
}
}
// CORRECT: use a different name or this. to distinguish
class PenghitungSkor {
int skor = 0;
void tambahPoin(int poin) { // different parameter name
skor += poin; // clear: 'skor' is the field
}
// Or if the names really must match:
void setSkor(int skor) {
this.skor = skor; // this. explicitly points to the field
}
}
Closures and Capture #
Functions in Dart can “capture” variables from their surrounding scope — this is called a closure:
Function buatPenghitung(int mulaiDari) {
int hitung = mulaiDari; // this variable is captured by the closure
return () {
hitung++; // accesses and modifies the outer variable
return hitung;
};
}
void main() {
var hitungA = buatPenghitung(0);
var hitungB = buatPenghitung(10);
print(hitungA()); // 1
print(hitungA()); // 2
print(hitungB()); // 11 — each closure's state is separate
print(hitungA()); // 3
}
Type Inference in Depth #
Dart performs type inference not just for simple variable declarations, but also for more complex expressions:
// Inference from literals
var angka = 42; // int
var teks = 'halo'; // String
var daftar = [1, 2, 3]; // List<int>
var peta = {'a': 1}; // Map<String, int>
// Inference from expressions
var hasil = 10 / 3; // double (not int, even though both operands are int)
var gabung = [1, 2] + [3, 4]; // ✗ not valid — Dart doesn't overload the + operator
// Inference in generics
var pasangan = {'nama': 'Budi', 'umur': 25};
// Dart infers: Map<String, Object> because the values mix String and int
// ANTI-PATTERN: var on a declaration without a value — the result is dynamic
var tanpaNilai; // ✗ Dart infers dynamic, losing type safety
tanpaNilai = 'teks'; // ✓ valid
tanpaNilai = 42; // ✓ also valid — but this is confusing
tanpaNilai = [1, 2, 3]; // ✓ also valid — not type-safe at all
// CORRECT: use an explicit type if you don't provide an initial value
String? namaDepan; // the type is clearly String?
int? skorUjian; // the type is clearly int?
dynamic vs Object vs var
#
These three are often confused because they look similar, but they have important differences:
// dynamic — disables the type checker entirely (avoid if possible)
dynamic apa = 'teks';
apa = 42; // ✓ valid
apa.metodeTidakAda(); // ✓ valid at compile time, ✗ crashes at runtime
// Object — supertype of all non-nullable types (type-safe, but needs a cast)
Object sesuatu = 'teks';
sesuatu = 42; // ✓ valid
// sesuatu.length; // ✗ compile error: Object has no .length
(sesuatu as String).length; // ✓ explicit cast required
// var — type inferred, still type-safe after inference
var inferensi = 'teks'; // inferred: String
// inferensi = 42; // ✗ error: int can't go to String
inferensi.length; // ✓ Dart knows this is String, .length is valid
| Keyword | Type checking | Value can change type | Safe |
|---|---|---|---|
var | ✓ full after inference | ✗ | ✓ |
Object | ✓ full (needs cast) | ✓ | ✓ |
dynamic | ✗ disabled | ✓ | ✗ |
// ANTI-PATTERN: using dynamic as an easy way to dodge types
dynamic data = ambilDariApi();
print(data.nama); // crashes at runtime if the API changes its format
// CORRECT: define a clear type or use pattern matching
Map<String, dynamic> data = ambilDariApi();
String nama = data['nama'] as String; // explicit cast with a clear error message
Variable Naming Conventions #
Dart has standardized naming conventions followed by the entire ecosystem:
// ✓ lowerCamelCase for variables and parameters
String namaLengkap = 'Budi Santoso';
int jumlahKunjungan = 0;
bool sudahLogin = false;
// ✓ _lowerCamelCase for private variables and methods
String _tokenSesi = '';
int _hitungInternal = 0;
// ✓ UpperCamelCase for class names and types
class PenggunaPremium { ... }
typedef KallbackData = void Function(String data);
// ✓ lowerCamelCase for constants (unlike Java/C which use SCREAMING_CASE)
const int batasUlangCoba = 3; // ✓ Dart style
// const int BATAS_ULANG_COBA = 3; // ✗ not Dart style
// ✓ Descriptive names, avoid uncommon abbreviations
int jumlahPengguna = 100; // ✓
int jlhPngg = 100; // ✗ unnecessary abbreviation
int n = 100; // ✗ too short, meaningless
// ANTI-PATTERN: meaningless variable names
var a = hitungTotal();
var b = ambilPengguna();
var c = a * 1.1;
// CORRECT: names that explain intent
double subtotal = hitungTotal();
Pengguna penggunaSaatIni = ambilPengguna();
double totalDenganPajak = subtotal * 1.1;
Summary #
varfor variables whose type is obvious from the initial value on the same line. Once the type is inferred, the variable is statically typed — not dynamic.- Explicit types for declarations without an initial value, function parameters, return types, and any situation where the type isn’t immediately clear to the reader.
finalfor variables that don’t need to change after being set. Makefinalthe default, switching tovaronly when the value genuinely needs to change.constfor values that are certain from compile time — literals, literal expressions, and class instances with const constructors. The contents of aconstcollection are truly immutable, not just the reference.String?vsString— add?only for variables that genuinely can lack a value. Non-nullable is the safe default; nullable is the exception that must be handled explicitly.- Null-aware operators (
??,?.,??=) are Dart’s idiomatic way to handle nullables without verboseif (x != null)chains.lateis the exception for cases where initialization truly can’t happen at the declaration point. Too manylates is a sign the design needs review.- Avoid
dynamic— it disables the type checker and moves all errors from compile time to runtime. UseObject, generics, or a proper union type instead.- Lexical scoping — variables can only be accessed from the block where they’re declared inward, never from outside. Avoid accidental shadowing by choosing distinct variable names.
- lowerCamelCase for all variable names, including constants — this differs from other languages that use
SCREAMING_CASEfor constants.