Skip to main content

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

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

TypeNotes
int, doubleBoth are num. On the web int is a JS number; on mobile it is 64-bit.
StringUTF-16. Interpolation with $name and ${expr}.
boolNo truthiness — if (x) requires an actual bool.
List, Set, MapSee Collections.
Object, dynamicObject? is the top type; dynamic disables static checking, so avoid it.
void, NeverNever marks code that never returns.
EnumEnhanced 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 const only if every field is final and every argument is a compile-time constant. Widgets follow this rule so they can be const.
  • factory constructors may return a cached or subtype instance.
  • Initialiser lists run before the body: User(this.id) : name = id.toUpperCase();
  • extends for inheritance, implements for interface-only, with for 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.

ModifierMeaning
abstractCannot be instantiated directly.
baseCan be extended, but not implemented outside its library.
interfaceCan be implemented, but not extended outside its library.
finalCannot be extended or implemented outside its library.
sealedImplicitly 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));
}
}
  • await only suspends the current function; it never blocks the thread.
  • Future.wait runs futures concurrently and fails fast on the first error.
  • A Stream is 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.