Dart essentials
The parts of Dart that Flutter code leans on daily. This is not a full language tour — it is the surface you need before reading Flutter source or writing widgets. Verified against the Dart release bundled with Flutter 3.44.
Contents
- Variables and null safety
- Built-in types
- Collections
- Functions
- Classes
- Class modifiers
- Generics
- Records
- Patterns
- Asynchrony
- Errors
Variables and null safety
Every type is non-nullable unless you add ?. This is the single biggest
influence on how Dart code reads.
int count = 0; // cannot hold null
int? maybeCount; // can hold null, defaults to null
var name = 'Rojar'; // inferred String, reassignable
final createdAt = DateTime.now(); // set once, at runtime
const maxRetries = 3; // compile-time constant
final means the binding cannot be reassigned; the object can still mutate.
const means the value is created at compile time and canonicalised — two
identical const objects are the same instance, which is why const widgets
are cheap.
Null-aware operators:
final length = maybeName?.length; // null if maybeName is null
final safe = maybeName ?? 'anonymous'; // fallback
maybeName ??= 'anonymous'; // assign only if null
final forced = maybeName!; // throws if null — use sparingly
late defers initialisation while keeping the type non-nullable. It is the
right tool for fields initialised in initState, and a runtime error waiting to
happen if you read before assigning.
late final AnimationController controller; // assigned in initState
Built-in types
| Type | Notes |
|---|---|
int, double | Both are num. On the web int is a JS number; on mobile it is 64-bit. |
String | UTF-16. Interpolation with $name and ${expr}. |
bool | No truthiness — if (x) requires an actual bool. |
List, Set, Map | See Collections. |
Object, dynamic | Object? is the top type; dynamic disables static checking, so avoid it. |
void, Never | Never marks code that never returns. |
Enum | Enhanced enums can have fields, constructors, and methods. |
Strings:
final multiline = '''
line one
line two''';
final raw = r'C:\my\build'; // no escape processing
final label = 'Total: ${items.length} items';
Enhanced enum:
enum Environment {
dev('https://dev.example.com'),
prod('https://example.com');
const Environment(this.baseUrl);
final String baseUrl;
bool get isProd => this == Environment.prod;
}
Collections
final numbers = <int>[1, 2, 3];
final unique = <String>{'a', 'b'};
final config = <String, Object?>{'retries': 3, 'debug': false};
Spread, conditional, and loop elements build collections declaratively — this is everywhere in widget lists:
final actions = <Widget>[
const Icon(Icons.search),
if (isAdmin) const Icon(Icons.settings),
...extraActions,
for (final tag in tags) Chip(label: Text(tag)),
];
Useful operations: map, where, firstWhere, any, every, fold,
expand, sort, take, skip. They return lazy Iterables — call toList()
when you need to materialise, and be aware that iterating twice re-runs the
work.
Functions
int add(int a, int b) => a + b; // arrow body
void log(String message, {String level = 'info'}) {} // named, with default
void save(String id, {required Object data}) {} // named, required
String greet(String name, [String? title]) => ...; // positional optional
Functions are objects. Tear-offs pass a method without wrapping it in a closure:
items.forEach(print); // instead of (e) => print(e)
onPressed: _submit, // method tear-off
Closures capture their environment, which is how callbacks in widgets keep
access to State fields.
Classes
class User {
const User({required this.id, required this.name, this.email});
final String id;
final String name;
final String? email;
// Named constructor
factory User.fromJson(Map<String, Object?> json) => User(
id: json['id']! as String,
name: json['name']! as String,
email: json['email'] as String?,
);
// Derived value
String get initials => name.isEmpty ? '?' : name[0].toUpperCase();
User copyWith({String? name}) => User(id: id, name: name ?? this.name, email: email);
}
Notes that matter in Flutter:
- A constructor can be
constonly if every field isfinaland every argument is a compile-time constant. Widgets follow this rule so they can beconst. factoryconstructors may return a cached or subtype instance.- Initialiser lists run before the body:
User(this.id) : name = id.toUpperCase(); extendsfor inheritance,implementsfor interface-only,withfor mixins.
Mixins and extensions:
mixin Loggable {
void log(String m) => print('[$runtimeType] $m');
}
class Repo with Loggable {}
extension StringX on String {
String get capitalised => isEmpty ? this : this[0].toUpperCase() + substring(1);
}
Class modifiers
Dart 3 added modifiers that constrain how a type can be used. They matter most
for modelling domain state, and they enable exhaustive switch.
| Modifier | Meaning |
|---|---|
abstract | Cannot be instantiated directly. |
base | Can be extended, but not implemented outside its library. |
interface | Can be implemented, but not extended outside its library. |
final | Cannot be extended or implemented outside its library. |
sealed | Implicitly abstract; all direct subtypes must be in the same file, so switch can be checked for exhaustiveness. |
sealed class Result<T> {}
final class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
final class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
Generics
class Cache<T> {
final _entries = <String, T>{};
T? read(String key) => _entries[key];
void write(String key, T value) => _entries[key] = value;
}
// Bounded type parameter
T largest<T extends Comparable<T>>(List<T> items) =>
items.reduce((a, b) => a.compareTo(b) >= 0 ? a : b);
Generics are reified in Dart — the type argument exists at runtime, so
list is List<String> works as expected.
Records
Records are lightweight, immutable, structurally typed groups of values. Use them to return more than one value without declaring a class.
// Positional
(double, double) center() => (12.5, 40.0);
final (x, y) = center();
// Named
({int width, int height}) size() => (width: 1920, height: 1080);
final s = size();
print('${s.width} x ${s.height}');
Records have value equality, so two records with equal fields are ==. They are
a good fit for a small return value or a map key, and a bad fit for a domain
model that other code depends on — a named class documents intent better.
Patterns
Patterns destructure and match. They pair with records and sealed classes.
Destructuring:
final (name, age) = ('Ada', 36);
final [first, second, ...rest] = numbers;
final {'id': id, 'title': title} = json;
Switch expressions are exhaustive over a sealed hierarchy — add a subtype and the compiler points at every switch you forgot:
String describe(Result<int> result) => switch (result) {
Success(value: final v) when v > 100 => 'big: $v',
Success(value: final v) => 'ok: $v',
Failure(error: final e) => 'failed: $e',
};
if-case matches a single shape, which is the cleanest way to read loosely
typed JSON:
if (json case {'user': {'name': final String name, 'age': final int age}}) {
return User(name: name, age: age); // both fields present and correctly typed
}
Asynchrony
Future<User> loadUser(String id) async {
final response = await api.get('/users/$id');
return User.fromJson(response.data);
}
Stream<int> countdown(int from) async* {
for (var i = from; i >= 0; i--) {
yield i;
await Future<void>.delayed(const Duration(seconds: 1));
}
}
awaitonly suspends the current function; it never blocks the thread.Future.waitruns futures concurrently and fails fast on the first error.- A
Streamis single-subscription by default; use a broadcast stream when more than one listener is required, and always cancel subscriptions.
The event loop, isolates, and the pitfalls of both are covered in Async and isolates.
Errors
try {
await repository.save(user);
} on TimeoutException catch (e) {
// specific type
} on FormatException catch (e, stackTrace) {
// with stack trace
} catch (e) {
rethrow; // preserves the original stack trace
} finally {
controller.close();
}
Convention: Exception for conditions the caller can reasonably handle,
Error for programming mistakes (ArgumentError, StateError) that should be
fixed rather than caught. Never catch Error to keep an app running — it hides
the bug.
For recoverable domain failures, returning a sealed result type gives the
caller an exhaustive switch instead of an invisible control-flow jump. That
pattern is used in
Networking and serialization.