Properties, Getters & Setters
Why Getters and Setters?
In the previous lessons, you learned about class properties and the underscore convention for privacy. But simply making a field private and exposing it directly is not always enough. You often need to:
- Compute values from other properties (e.g., a
fullNamefromfirstNameandlastName) - Validate data before setting a property (e.g., age must be positive)
- React to changes when a property is updated (e.g., log the change, notify listeners)
- Control access -- make a property read-only or write-only from outside
This is where getters and setters come in. They look like properties to the outside world but execute code behind the scenes.
Implicit Getters and Setters
Every non-private field in Dart automatically gets an implicit getter and setter. When you write person.name, you are calling the getter. When you write person.name = 'Ahmed', you are calling the setter.
Implicit vs Explicit
class Person {
// These two fields have implicit getters and setters
String name;
int age;
Person(this.name, this.age);
}
void main() {
var p = Person('Ahmed', 25);
// These use the implicit getter
print(p.name); // Ahmed
print(p.age); // 25
// These use the implicit setter
p.name = 'Sara';
p.age = 30;
print(p.name); // Sara
print(p.age); // 30
}
final fields, Dart only generates a getter (no setter), because final fields cannot be reassigned after initialization. This is how you create read-only properties at the language level.Custom Getters
A custom getter is defined using the get keyword. It looks like a property but computes its value each time it is accessed. Getters have no parameters and use the arrow (=>) or block ({}) syntax.
Computed Properties with Getters
class Person {
String firstName;
String lastName;
DateTime birthDate;
Person({
required this.firstName,
required this.lastName,
required this.birthDate,
});
// Computed getter: full name
String get fullName => '$firstName $lastName';
// Computed getter: age (calculated from birth date)
int get age {
DateTime now = DateTime.now();
int years = now.year - birthDate.year;
if (now.month < birthDate.month ||
(now.month == birthDate.month && now.day < birthDate.day)) {
years--;
}
return years;
}
// Computed getter: initials
String get initials => '${firstName[0]}${lastName[0]}';
// Computed getter: is adult
bool get isAdult => age >= 18;
@override
String toString() => '$fullName (age: $age)';
}
void main() {
var person = Person(
firstName: 'Ahmed',
lastName: 'Hassan',
birthDate: DateTime(1995, 6, 15),
);
print(person.fullName); // Ahmed Hassan
print(person.age); // 30 (or whatever the current age is)
print(person.initials); // AH
print(person.isAdult); // true
// Getters are accessed like properties, NOT methods
// person.fullName -- correct (getter)
// person.fullName() -- ERROR! Not a method
}
fullName, area, isEmpty). Use a method when the computation is expensive, has side effects, or represents an action (verb-like: calculate(), fetchData(), save()).Custom Setters
A custom setter is defined using the set keyword. It takes exactly one parameter and runs code when a value is assigned. Setters are commonly used for validation, transformation, or triggering side effects.
Setters with Validation
class Temperature {
double _celsius;
Temperature(this._celsius);
// Getter: read celsius
double get celsius => _celsius;
// Setter: validate before setting celsius
set celsius(double value) {
if (value < -273.15) {
throw ArgumentError('Temperature cannot be below absolute zero (-273.15°C)');
}
_celsius = value;
}
// Getter: convert to fahrenheit
double get fahrenheit => (_celsius * 9 / 5) + 32;
// Setter: set via fahrenheit, stored as celsius
set fahrenheit(double value) {
celsius = (value - 32) * 5 / 9; // Uses the celsius setter (with validation!)
}
// Getter: convert to kelvin
double get kelvin => _celsius + 273.15;
// Setter: set via kelvin
set kelvin(double value) {
celsius = value - 273.15; // Uses the celsius setter
}
@override
String toString() =>
'${celsius.toStringAsFixed(1)}°C / ${fahrenheit.toStringAsFixed(1)}°F / ${kelvin.toStringAsFixed(1)}K';
}
void main() {
var temp = Temperature(100);
print(temp); // 100.0°C / 212.0°F / 373.1K
// Set via fahrenheit
temp.fahrenheit = 32;
print(temp); // 0.0°C / 32.0°F / 273.1K
// Set via kelvin
temp.kelvin = 0;
print(temp); // -273.1°C / -459.7°F / 0.0K
// Validation works
try {
temp.celsius = -300; // Below absolute zero!
} catch (e) {
print(e); // Invalid argument: Temperature cannot be below absolute zero
}
}
Private Fields with Public Getters/Setters
The most common OOP pattern is to make fields private (_) and expose them through getters and setters. This gives you full control over how data is read and written.
Encapsulation Pattern
class BankAccount {
String _owner;
double _balance;
List<String> _transactions = [];
BankAccount({required String owner, double initialBalance = 0})
: _owner = owner,
_balance = initialBalance;
// Read-only: owner (getter only, no setter)
String get owner => _owner;
// Read-only: balance
double get balance => _balance;
// Read-only: copy of transactions (prevents external modification)
List<String> get transactions => List.unmodifiable(_transactions);
// Read-only: computed
int get transactionCount => _transactions.length;
bool get isEmpty => _balance == 0;
// Controlled write access through methods (not setters)
void deposit(double amount) {
if (amount <= 0) throw ArgumentError('Amount must be positive');
_balance += amount;
_transactions.add('+\$${amount.toStringAsFixed(2)}');
}
void withdraw(double amount) {
if (amount <= 0) throw ArgumentError('Amount must be positive');
if (amount > _balance) throw StateError('Insufficient funds');
_balance -= amount;
_transactions.add('-\$${amount.toStringAsFixed(2)}');
}
@override
String toString() =>
'Account($owner, \$${_balance.toStringAsFixed(2)}, ${_transactions.length} transactions)';
}
void main() {
var account = BankAccount(owner: 'Ahmed', initialBalance: 1000);
account.deposit(500);
account.withdraw(200);
print(account.balance); // 1300.0
print(account.owner); // Ahmed
print(account.transactions); // [+$500.00, -$200.00]
print(account.transactionCount); // 2
// Cannot modify directly:
// account.balance = 9999; // ERROR! No setter
// account._balance = 9999; // ERROR from other files! (private)
// account.transactions.add('hack'); // No effect! It is unmodifiable
}
List.unmodifiable() or a copy. If you return the original list directly, outside code can modify it and break your encapsulation. For example: account.transactions.add('hack') would actually modify the internal list if you returned it directly.Getter-Only (Read-Only) Properties
Sometimes you want a property that can only be read, never set from outside. There are several ways to achieve this:
Three Ways to Make Read-Only Properties
class Config {
// Way 1: final field (set once in constructor, implicit getter only)
final String appName;
// Way 2: private field + public getter
double _version;
double get version => _version;
// Way 3: computed getter (no backing field)
String get displayName => '$appName v${_version}';
Config({required this.appName, required double version})
: _version = version;
// Internal method can still modify _version
void updateVersion(double newVersion) {
if (newVersion > _version) {
_version = newVersion;
}
}
}
void main() {
var config = Config(appName: 'MyApp', version: 1.0);
print(config.appName); // MyApp
print(config.version); // 1.0
print(config.displayName); // MyApp v1.0
// config.appName = 'Other'; // ERROR! final
// config.version = 2.0; // ERROR! no setter
// config.displayName = 'X'; // ERROR! no setter
config.updateVersion(2.0); // OK -- internal method
print(config.displayName); // MyApp v2.0
}
Setter-Only (Write-Only) Properties
Rarely, you may want a property that can be set but not read. This is uncommon but useful for things like password fields or configuration injection.
Write-Only Property
class SecureService {
String _apiKey = '';
// Write-only: can set the key but never read it back
set apiKey(String key) {
if (key.length < 10) {
throw ArgumentError('API key must be at least 10 characters');
}
_apiKey = key;
}
// Can use the key internally
bool get isConfigured => _apiKey.isNotEmpty;
String makeRequest(String endpoint) {
if (!isConfigured) throw StateError('API key not set');
// Use _apiKey internally
return 'Requesting $endpoint with key ${_apiKey.substring(0, 3)}***';
}
}
void main() {
var service = SecureService();
service.apiKey = 'my-secret-api-key-12345'; // OK -- setter
print(service.isConfigured); // true
print(service.makeRequest('/users'));
// Requesting /users with key my-***
// print(service.apiKey); // ERROR! No getter -- cannot read it back
}
Static Properties
Static properties belong to the class itself, not to any instance. They are shared across all objects of the class and accessed using the class name.
Static Properties and Methods
class Counter {
// Static property: shared across all instances
static int _totalCount = 0;
// Instance property: unique to each object
final String name;
int _count = 0;
Counter(this.name) {
_totalCount++; // Increment when a new counter is created
}
// Instance getter
int get count => _count;
// Static getter: access without an instance
static int get totalCounters => _totalCount;
void increment() => _count++;
@override
String toString() => '$name: $_count';
}
void main() {
print(Counter.totalCounters); // 0
var likes = Counter('Likes');
var views = Counter('Views');
var shares = Counter('Shares');
print(Counter.totalCounters); // 3
likes.increment();
likes.increment();
views.increment();
print(likes); // Likes: 2
print(views); // Views: 1
print(shares); // Shares: 0
// Static is accessed on the CLASS, not on instances
// likes.totalCounters; // Works but bad practice
// Counter.totalCounters; // Correct way
}
Static Constants and Utility Properties
class MathHelper {
// Static constants -- accessible without creating an instance
static const double pi = 3.14159265358979;
static const double e = 2.71828182845905;
static const double goldenRatio = 1.61803398874989;
// Static computed getters
static double get piSquared => pi * pi;
// Private constructor -- prevents instantiation
MathHelper._();
// Static methods using the constants
static double circleArea(double radius) => pi * radius * radius;
static double circleCircumference(double radius) => 2 * pi * radius;
}
void main() {
print(MathHelper.pi); // 3.14159265358979
print(MathHelper.piSquared); // 9.869604401...
print(MathHelper.circleArea(5)); // 78.5398...
// MathHelper(); // ERROR! Private constructor
}
Colors.blue, Icons.home, TextStyle.lerp(). They provide convenient access to predefined values without creating objects.Late Properties
The late keyword lets you declare a non-nullable property without initializing it immediately. It promises Dart that you will assign it before it is used.
Late Properties
class UserProfile {
final String username;
// Late: will be initialized later
late String _bio;
late DateTime _lastLogin;
// Late + lazy initialization: computed only when first accessed
late final String greeting = 'Welcome back, $username!';
UserProfile(this.username);
void setBio(String bio) {
if (bio.length > 500) {
throw ArgumentError('Bio must be 500 characters or less');
}
_bio = bio;
}
void recordLogin() {
_lastLogin = DateTime.now();
}
String get bio => _bio;
DateTime get lastLogin => _lastLogin;
}
void main() {
var profile = UserProfile('ahmed_dev');
// Late final with lazy initialization
print(profile.greeting); // Welcome back, ahmed_dev!
// Must set late fields before reading
profile.setBio('Flutter developer from Cairo');
profile.recordLogin();
print(profile.bio); // Flutter developer from Cairo
print(profile.lastLogin); // Current datetime
// Reading an uninitialized late field throws LateInitializationError
var profile2 = UserProfile('sara');
// print(profile2.bio); // ERROR! LateInitializationError
}
late variable before it has been assigned, Dart throws a LateInitializationError at runtime. Use late only when you are certain the field will be initialized before it is accessed. If there is any doubt, use a nullable type (Type?) instead.Practical Example: E-Commerce Product
Complete Example with All Property Types
class Product {
// Private fields
String _name;
double _price;
int _stock;
double _discountPercent;
// Static property
static int _totalProducts = 0;
static double _taxRate = 0.15; // 15% tax
// Final field (set once)
final String sku;
final DateTime createdAt;
Product({
required String name,
required double price,
required this.sku,
int stock = 0,
double discountPercent = 0,
}) : _name = name,
_price = price,
_stock = stock,
_discountPercent = discountPercent,
createdAt = DateTime.now() {
_totalProducts++;
}
// --- Getters (read access) ---
String get name => _name;
double get price => _price;
int get stock => _stock;
double get discountPercent => _discountPercent;
// Computed getters
double get discountAmount => _price * (_discountPercent / 100);
double get discountedPrice => _price - discountAmount;
double get tax => discountedPrice * _taxRate;
double get finalPrice => discountedPrice + tax;
bool get inStock => _stock > 0;
bool get isOnSale => _discountPercent > 0;
// Static getters
static int get totalProducts => _totalProducts;
static double get taxRate => _taxRate;
// --- Setters (write access with validation) ---
set name(String value) {
if (value.trim().isEmpty) throw ArgumentError('Name cannot be empty');
_name = value.trim();
}
set price(double value) {
if (value < 0) throw ArgumentError('Price cannot be negative');
_price = value;
}
set stock(int value) {
if (value < 0) throw ArgumentError('Stock cannot be negative');
_stock = value;
}
set discountPercent(double value) {
if (value < 0 || value > 100) {
throw ArgumentError('Discount must be between 0 and 100');
}
_discountPercent = value;
}
// Static setter
static set taxRate(double value) {
if (value < 0 || value > 1) {
throw ArgumentError('Tax rate must be between 0 and 1');
}
_taxRate = value;
}
@override
String toString() {
String result = '$name (SKU: $sku) - ';
if (isOnSale) {
result += '\$${_price.toStringAsFixed(2)} → \$${discountedPrice.toStringAsFixed(2)} (-${_discountPercent}%)';
} else {
result += '\$${_price.toStringAsFixed(2)}';
}
result += ' + tax: \$${tax.toStringAsFixed(2)} = \$${finalPrice.toStringAsFixed(2)}';
result += ' | Stock: $_stock';
return result;
}
}
void main() {
var laptop = Product(name: 'MacBook Pro', price: 1999.99, sku: 'MBP-001', stock: 50);
var phone = Product(name: 'iPhone 15', price: 999.99, sku: 'IPH-015', stock: 100, discountPercent: 10);
print(laptop);
// MacBook Pro (SKU: MBP-001) - $1999.99 + tax: $300.00 = $2299.99 | Stock: 50
print(phone);
// iPhone 15 (SKU: IPH-015) - $999.99 → $899.99 (-10.0%) + tax: $135.00 = $1034.99 | Stock: 100
print('Total products created: ${Product.totalProducts}'); // 2
// Use setter with validation
phone.discountPercent = 25;
print(phone.finalPrice.toStringAsFixed(2)); // 862.49
// Validation prevents bad data
try {
phone.price = -50; // ArgumentError!
} catch (e) {
print(e); // Invalid argument: Price cannot be negative
}
}
Practice Exercise
Open DartPad and build a UserSettings class: (1) Private fields: _theme (String, default “light”), _fontSize (double, default 16.0), _notificationsEnabled (bool, default true), _language (String, default “en”). (2) Add getters for all fields. (3) Add a theme setter that only accepts “light”, “dark”, or “system”. (4) Add a fontSize setter that only accepts values between 10.0 and 32.0. (5) Add a language setter that only accepts “en”, “ar”, “fr”, or “es”. (6) Add a computed getter isRTL that returns true if language is “ar”. (7) Add a static property defaultSettings that returns a new UserSettings with all defaults. (8) Override toString() and test all getters, setters, and validation.