Skip to content

Dart for you

Dart is a garbage-collected, single-threaded-per-isolate, statically typed language with sound null safety. If you squint it is TypeScript with Kotlin’s class model and a bit of Swift’s pattern matching, running on an event loop like Node. This page skips everything that works the way you would guess and spends its time on the parts that don’t.

Every example below is from footrace-flutter at d1b602a. The pubspec asks for Dart ^3.0.0 (app/pubspec.yaml:7-8), and the toolchain is Flutter 3.47.2 (app/.tool-versions:1), so all of Dart 3 is available: records, patterns, sealed classes, class modifiers.

One thing to know before reading any file: this repo has no code generation. No freezed, no json_serializable, no riverpod_generator, no part directives. Every fromJson, toJson and copyWith is written by hand. That makes the files longer than in many Flutter projects, but it means what you read is what runs.

Idea Dart (as used here) TypeScript Kotlin Swift Go Elixir
Immutable binding final x = 1; const x = 1 val x = 1 let x = 1 (none) every binding
Compile-time constant const x = 1; as const (roughly) const val static let (roughly) const module attribute @x
Nullable type String? string | null String? String? *string nil is just an atom
Default if null a ?? b a ?? b a ?: b a ?? b if a == nil a || b
Safe call a?.b a?.b a?.b a?.b nil check a && a.b
Force unwrap a! a! a!! a! deref (n/a)
Data class class with final fields, hand-written copyWith interface + spread data class struct struct struct / map
Sum type sealed class + subclasses discriminated union sealed class enum with payloads interface + type switch tagged tuples
Pattern match switch expression with patterns switch on tag when switch type switch case
Tuple record (a, b) [a, b] Pair tuple multiple returns {a, b}
Interface any class, via implements interface interface protocol interface behaviour
Mix in behaviour with SomeMixin (none) delegation protocol extension embedding use
Extension method extension X on T (none) extension fun extension (none) (none)
One async value Future<T> Promise<T> Deferred<T> / suspend async / Task channel / goroutine Task
Async sequence Stream<T> AsyncIterable / RxJS Flow<T> AsyncSequence channel Stream / messages
Unit of concurrency isolate (own heap) worker coroutine (shared heap) actor / task goroutine (shared heap) process (own heap)
Privacy leading _, per file private / #x private private lower-case name defp
Named arguments {required String email} options object named args labels (none) keyword list
Chain calls on one object cascade .. (none) apply {} (none) (none) pipe, roughly

The rest of the page takes these one at a time.

A Dart file is a library. Privacy is per library, not per class: anything whose name starts with _ is visible only inside its file. That is why the router’s _redirect function (app/lib/app/router.dart:136) needs a public wrapper, redirectFor, marked @visibleForTesting so tests can call it (app/lib/app/router.dart:132-134). There is no private keyword and no class-level privacy.

Imports are by path or by package. package:footrace_flutter/... is this app; package:flutter_riverpod/... is a dependency. Two modifiers you will see:

// app/lib/core/config/app_config.dart:1
import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode;
// app/lib/features/auth/auth_notifier.dart:12
export 'auth_state.dart';

show imports only the names listed (like import { a, b } from). export re-exports another file, so anything importing auth_notifier.dart also gets AuthState and AuthStatus.

  • var and final work like Kotlin var and val. Types are inferred.
  • const means compile-time constant, and it is stronger than in TS or Kotlin: a const object is built by the compiler and canonicalised, so two identical const expressions are the same instance.
  • late defers initialisation to first use.

const matters most for constructors. A class whose constructor is marked const (all fields final) can be instantiated at compile time. AuthState has three named const constructors that all redirect to one private one:

// app/lib/features/auth/auth_state.dart:11-27
class AuthState {
const AuthState._({
required this.status,
this.user,
this.stats,
this.offline = false,
});
const AuthState.unknown() : this._(status: AuthStatus.unknown);
const AuthState.signedOut() : this._(status: AuthStatus.signedOut);
const AuthState.signedIn(User user, {UserStats? stats, bool offline = false})
: this._(
// ...

So return const AuthState.signedOut(); (app/lib/features/auth/auth_notifier.dart:26) allocates nothing at runtime. In Flutter, const on a widget also tells the framework the widget cannot have changed, which lets it skip rebuilding that subtree; you will see const sprinkled everywhere in build methods for that reason.

The other strong use of const here is configuration. --dart-define values are compile-time constants, read with String.fromEnvironment, so AppConfig.fromEnvironment is itself a static const:

// app/lib/core/config/app_config.dart:41-48
static const fromEnvironment = AppConfig(
apiBaseUrl: String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:4000',
),
devTools: bool.fromEnvironment('DEV_TOOLS', defaultValue: kDebugMode),
fakeGps: bool.fromEnvironment('FAKE_GPS'),
);

The consequence: changing a define means restarting flutter run, not hot reload. See build and release.

late shows up only in two animated widgets, where the field needs this (the vsync) to initialise and so cannot be built in the field initialiser of a normal final:

// app/lib/design/components/fr_section_header.dart:107-110
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);

That is Kotlin’s by lazy, not Kotlin’s lateinit: the initialiser runs on first read.

Dart’s null safety is sound: a String can never be null, at compile time or at runtime. The operators are the ones you know from Kotlin and Swift.

// app/lib/features/auth/auth_notifier.dart:131-134
final authStatusProvider = Provider<AuthStatus>((ref) {
final auth = ref.watch(authProvider);
return auth.value?.status ?? AuthStatus.unknown;
});

??= assigns only if the left side is null. The location service uses it to make sure only one upload drain runs at a time:

// app/lib/tracking/location_service.dart:175
_uploadInFlight ??= () async {

One gotcha that will trip you up while reading: null checks on fields do not promote the type in most cases, only checks on local variables do. So you will see ! right after a null check:

// app/lib/data/models/user.dart:20-22
String get displayName => (name != null && name!.trim().isNotEmpty)
? name!
: email.split('@').first;

name is a public field, and a subclass could override it with a getter that returns something different the second time, so the compiler will not trust the first check. The common workaround is to copy into a local first, which is what final current = state.value; followed by if (current == null ...) return; does in app/lib/features/auth/auth_notifier.dart:83-84.

JSON decoding leans on casts and null defaults. as String? is an unchecked-at-compile-time cast that throws at runtime if wrong, the same as Kotlin’s as:

// app/lib/data/models/race_snapshot.dart:65-70
factory RaceTypeInfo.fromJson(Map<String, dynamic> json) => RaceTypeInfo(
slug: json['slug'] as String? ?? '',
title: json['title'] as String? ?? '',
distanceMeters:
((json['distanceMeters'] ?? json['meters']) as num?)?.toInt() ?? 0,
);

dynamic turns type checking off for that value (TypeScript’s any). It appears almost only at JSON and platform boundaries.

Dart has more constructor shapes than any language you have used. All of these appear in the repo:

Shape Example Analogy
this.x shorthand const User({required this.id, ...}) (app/lib/data/models/user.dart:5-11) Kotlin primary constructor val params
Named parameters, required same Swift labels, Kotlin named args
Optional positional [...] SecureTokenStore([FlutterSecureStorage? storage]) (app/lib/data/storage/token_store.dart:13) default params
Initialiser list after : : _storage = storage ?? const FlutterSecureStorage() (app/lib/data/storage/token_store.dart:14) Swift init before super.init
Named constructor ApiException.network() (app/lib/data/api/api_exception.dart:12-17) static factory method
Private constructor RaceChannel._(...) (app/lib/data/socket/race_channel.dart:81) Kotlin private constructor
Redirecting AuthState.unknown() : this._(...) (app/lib/features/auth/auth_state.dart:19) Kotlin secondary : this(...)
factory User.fromJson (app/lib/data/models/user.dart:24-32) companion fun from(...)
super.key const FrEyebrow(this.text, {super.key, ...}) (app/lib/design/components/fr_eyebrow.dart:9) pass-through to superclass

A factory constructor is called like a constructor but may return an existing instance or a subtype; in this codebase it is used almost entirely for fromJson. RaceChannel combines a private constructor with a static Future method, because construction needs an await (the channel join) and constructors cannot be async:

// app/lib/data/socket/race_channel.dart:91-94
static Future<RaceChannel> join(SocketTransport socket, String raceId) async {
final (channel, reply) = await socket.join(topicFor(raceId));
return RaceChannel._(channel, RaceJoinReply.fromJson(reply), raceId);
}

Named parameters are optional unless marked required. Call sites must name them: _repo.login(email: email, password: password) (app/lib/features/auth/auth_notifier.dart:49).

Every Dart class is also an implicit interface. There is no separate interface keyword in everyday code: you write an abstract class and implements it. This is the pattern behind every swap-for-a-fake seam in the app:

// app/lib/data/storage/token_store.dart:6-12
abstract class TokenStore {
Future<String?> read();
Future<void> write(String token);
Future<void> clear();
}
class SecureTokenStore implements TokenStore {

InMemoryTokenStore implements the same interface lower in the file (app/lib/data/storage/token_store.dart:29-41), and tests swap it in through Riverpod. implements means “same shape, no inherited code”, like a Go interface made explicit. extends is ordinary single inheritance.

Dart 3 added class modifiers. The one you will meet is abstract final class, which can be neither instantiated nor subclassed. It is used as a namespace for constants:

// app/lib/app/routes.dart:2-5
abstract final class AppRoutes {
static const splash = '/splash';
static const login = '/login';
static const register = '/register';

FrColors, FrSpacing, FrRadii, FrShadows and FrFonts in app/lib/design/tokens.dart follow the same pattern. Think of it as a Swift caseless enum used as a namespace.

Dart enums can have methods, getters and static members (Dart 2.17’s “enhanced enums”), much like Kotlin and Swift. RaceStatus parses the backend’s spellings with a static method:

// app/lib/data/models/race_snapshot.dart:5-22
enum RaceStatus {
countdown,
racing,
won,
finished,
aborted,
unknown;
static RaceStatus parse(String? s) => switch (s) {
'countdown' || 'not_started' => RaceStatus.countdown,
'racing' => RaceStatus.racing,
// ...
_ => RaceStatus.unknown,
};
bool get isOver => this == finished || this == aborted;

.name gives the case name as a string, which wire returns (app/lib/data/models/race_snapshot.dart:26). Unlike Swift, Dart enum cases cannot carry per-case payloads. When you need that, you use a sealed class.

This is where Dart feels most like Kotlin, and where your Elixir instincts pay off. A sealed class can only be subclassed in its own file, so the compiler knows every subtype and can check a switch for exhaustiveness. The matchmaking queue’s state is one:

// app/lib/features/queue/queue_notifier.dart:15-62 (trimmed)
sealed class QueueState {
const QueueState();
}
class QueueIdle extends QueueState { const QueueIdle(); }
class QueueJoining extends QueueState { const QueueJoining(); }
class QueueWaiting extends QueueState {
const QueueWaiting({required this.competitorId, required this.since, this.polling = false});
final String competitorId;
final DateTime since;
final bool polling;
}
// ... QueueMatched, QueueCancelled, QueueError

The screen consumes it with a switch expression and object patterns:

// app/lib/features/queue/queue_screen.dart:114-136 (trimmed)
child: switch (state) {
QueueIdle() || QueueJoining() => const _Centered(
child: CircularProgressIndicator(),
),
QueueWaiting(:final since) => _Waiting(since: since, /* ... */),
QueueMatched() => _Matched(state: state, now: now),
QueueCancelled() => MessageState(/* ... */),
QueueError(:final message, :final canRetry) => MessageState(/* ... */),
},

Reading the patterns:

  • QueueIdle() matches by type, like Kotlin’s is QueueIdle ->.
  • || combines patterns, like Elixir’s multiple heads or Kotlin’s comma in when.
  • QueueWaiting(:final since) matches the type and binds its since field to a local. It is short for QueueWaiting(since: final since). This is the closest Dart gets to Elixir’s %QueueWaiting{since: since}.
  • No _ default is needed, and if someone adds a seventh QueueState subclass, this file stops compiling until it is handled.

The same shape appears for race-channel events (app/lib/data/socket/race_channel.dart:5-42), consumed by a switch statement in RaceNotifier._onEvent (app/lib/features/race/race_notifier.dart:178-199). Statement cases do not fall through, so there is no break between them; the one explicit break on line 190 is an empty case body.

Where an analogy breaks: Elixir pattern matching is structural on any term. Dart’s patterns are typed. They match on class types and their getters, on records, lists, maps and constants, but there is no “match any map that has a type key equal to batch” without a map pattern, and this codebase does not use map patterns. When it needs that it switches on a value instead: switch (data['type']) { case 'batch': ... (app/lib/features/race/race_notifier.dart:202-203).

Records are anonymous, immutable, typed tuples, new in Dart 3. The socket abstraction returns two things from a join, so its signature uses a record type:

// app/lib/data/socket/channel_transport.dart:57-60
Future<(ChannelTransport, Map<String, dynamic>)> join(
String topic, {
Map<String, dynamic>? params,
});

The implementation builds one with plain parentheses (app/lib/data/socket/phoenix_transport.dart:123), and the caller destructures it: final (channel, reply) = await socket.join(topicFor(raceId)); (app/lib/data/socket/race_channel.dart:92). That is Go’s multiple return values or Elixir’s {:ok, channel, reply}, except the record is a real value you can store and pass around, and it has structural equality.

The more common use is local: a switch expression that yields a record, destructured on the spot. The design components pick colour pairs this way:

// app/lib/design/components/fr_tag.dart:32-35
final (background, foreground) = switch (tone) {
FrTagTone.neutral => (fr.surfaceMuted, fr.ink),
FrTagTone.accent => (fr.accentSoft, fr.accent),
FrTagTone.dark => (fr.ink, FrColors.onInk),

FrIconTile, FrButton and FrSegmentedControl do the same, and the history row picks its icon and label with one (app/lib/features/history/history_tab.dart:180-185). A record pattern also works in a for loop: for (final (i, runner) in item.competitors.indexed) (app/lib/features/activity/activity_tab.dart:164) is Elixir’s Enum.with_index or Go’s for i, runner := range.

Functions are first-class, and typedef names a function type:

// app/lib/core/clock.dart:5-7
typedef Clock = DateTime Function();
final clockProvider = Provider<Clock>((_) => DateTime.now);

Note DateTime.now without parentheses: that is a tear-off, a reference to the function itself, like Swift’s DateTime.now as a closure or Kotlin’s ::now. Tests replace it with a controllable clock. Constructors can be torn off too, with .new, and every Riverpod notifier provider in the app does exactly that: AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new) (app/lib/features/auth/auth_notifier.dart:126-127).

=> is shorthand for a one-expression body, and it is everywhere. Generic functions look like TypeScript’s:

// app/lib/data/api/api_client.dart:86-93
/// Runs [call] and rethrows any Dio failure as [ApiException].
Future<T> guard<T>(Future<T> Function() call) async {
try {
return await call();
} on DioException catch (e) {
throw toApiException(e);
}
}

Every repository method wraps its HTTP call in guard, so callers only ever see ApiException.

Collections, if/for inside literals, spreads and cascades

Section titled “Collections, if/for inside literals, spreads and cascades”

List, map and set literals can contain if, for and ... spreads. That is how the app builds the root ProviderScope’s observer list only in debug builds:

// app/lib/main.dart:16-19
const ProviderScope(
observers: [if (kDebugMode) LoggingProviderObserver()],
child: FootraceApp(),
),

The router adds the dev route the same way (app/lib/app/router.dart:117-121). A for inside a map literal builds a lookup table without fold or Map.fromEntries:

// app/lib/features/race_tab/race_tab.dart:37-40
final statsBySlug = {
for (final s in auth?.stats?.byRaceType ?? const <RaceTypeStats>[])
s.raceType.slug: s,
};

The cascade .. calls a method on an object and returns the object, not the method’s result. It is the reason a void method can be chained:

// app/lib/features/lobby/lobby_screen.dart:76-78
ref.read(lobbyProvider.notifier)
..startPolling()
..poll();

Read it as Kotlin’s apply { startPolling(); poll() }. The idiom ...toList()..sort(...) appears in app/lib/data/repositories/race_type_repository.dart:37-40: sort returns void, so the cascade is what lets the expression evaluate to the sorted list.

An extension adds members to an existing type, resolved statically, exactly like Kotlin and Swift extensions. The design system’s one extension is the reason widgets write context.fr:

// app/lib/design/typography.dart:309-313
extension FrThemeContext on BuildContext {
FrTheme get fr => Theme.of(this).extension<FrTheme>() ?? FrTheme.light;
}

A mixin is a bundle of fields and methods that a class pulls in with with. It is closer to a Ruby module than to anything in Kotlin or Swift, because it can carry state. The queue screen mixes in WidgetsBindingObserver to get app-lifecycle callbacks (app/lib/features/queue/queue_screen.dart:35-36), and the animated widgets mix in SingleTickerProviderStateMixin so this can be the animation’s vsync (app/lib/design/components/fr_section_header.dart:105-106). The app defines no mixins of its own.

Future<T> is a Promise<T>. async/await work the same way, on a single-threaded event loop with a microtask queue, as in JavaScript. A Future starts running when it is created, not when awaited. Exceptions propagate through await. Session restore reads like ordinary sequential code:

// app/lib/features/auth/auth_notifier.dart:24-31
Future<AuthState> build() async {
final token = await _tokens.read();
if (token == null || token.isEmpty) return const AuthState.signedOut();
try {
final me = await _repo.me();
await _cache.write(me.user);
return AuthState.signedIn(me.user, stats: me.stats);
} on ApiException catch (e) {

on Type catch (e) filters by type, like Kotlin’s catch (e: ApiException). on Object catches anything, including non-Exception throws (app/lib/data/socket/race_channel.dart:60). rethrow rethrows with the original stack (app/lib/data/repositories/auth_repository.dart:52). Dart has no checked exceptions; the docs comment is the contract (“Throws [ApiException] on failure”, app/lib/features/auth/auth_notifier.dart:47).

Things that look odd at first:

  • Fire and forget. Calling an async function without await is legal and common. When the error still matters, the code attaches .catchError, as the socket lifecycle does (app/lib/data/socket/socket_service.dart:29-31).
  • Future.microtask schedules work after the current synchronous code finishes. The queue screen uses it to start matchmaking right after initState, because Riverpod does not let a widget modify providers in the middle of building (app/lib/features/queue/queue_screen.dart:48-49).
  • An immediately invoked async closure. _uploadInFlight ??= () async { ... }(); (app/lib/tracking/location_service.dart:175-193) creates and starts a Future, stores it, and clears the field in finally. That is the JS (async () => { ... })() idiom, used as a one-at-a-time lock.

Stream<T> is an asynchronous sequence of events: Kotlin Flow, RxSwift Observable, an Elixir process mailbox if you are generous. You listen and get back a StreamSubscription that you must cancel, which is why the notifiers keep subscriptions in fields and cancel them on dispose (app/lib/features/race/race_notifier.dart:117-119).

Two kinds matter:

  • Single-subscription streams allow one listener and buffer until it arrives.
  • Broadcast streams allow many listeners and drop events nobody is listening for. Every StreamController in this app is .broadcast(), for example the tracker’s message stream (app/lib/tracking/race_tracker.dart:30).

Where the Kotlin analogy breaks: a Flow is cold and runs once per collector. A Dart stream from a StreamController is hot; events flow whether or not you listen. An async* function, on the other hand, produces a cold stream, and the app has exactly one:

// app/lib/data/socket/socket_service.dart:41-45
final socketStatusProvider = StreamProvider<SocketStatus>((ref) async* {
final transport = ref.watch(socketTransportProvider);
yield transport.status;
yield* transport.statusStream;
});

yield emits one value; yield* forwards everything from another stream. Stream operators look like Rx: RaceChannel.events maps raw channel messages to typed events and drops the unknown ones with .map(parseRaceEvent).where((e) => e != null).cast<RaceEvent>() (app/lib/data/socket/race_channel.dart:97-100).

Dart code runs in isolates. Each isolate has its own heap and its own event loop, and isolates share no memory; they talk by sending messages that are copied. That is the BEAM process model, and the analogy is honest as far as it goes. Where it breaks: isolates are heavyweight (one per OS thread, megabytes, not a few hundred bytes), there is no supervisor tree, no links or monitors, and you do not spawn thousands of them.

Most Flutter apps never spawn one. Footrace has exactly one extra isolate, and it is load-bearing: the GPS foreground service. The plugin starts it from a top-level function that must survive ahead-of-time compilation’s tree shaking, hence the pragma:

// app/lib/tracking/location_service.dart:24-29
/// Entry point for the foreground-service isolate.
/// Must be top-level and annotated so the Dart AOT compiler keeps it.
@pragma('vm:entry-point')
void startLocationCallback() {
FlutterForegroundTask.setTaskHandler(LocationTaskHandler());
}

Because the service isolate has its own heap, it cannot see Riverpod, the token store or any other app state. The UI isolate writes what the service needs into the plugin’s key-value store before starting it (app/lib/tracking/location_service.dart:329-348), the service reads it back in onStart (app/lib/tracking/location_service.dart:77-91), and fixes come back as plain maps through FlutterForegroundTask.sendDataToMain (app/lib/tracking/location_service.dart:150), received by a callback registered in the UI isolate (app/lib/tracking/race_tracker.dart:48). Only maps, lists, strings and numbers cross the boundary, which is why the service builds each fix as a map (app/lib/tracking/location_service.dart:120-128) and the UI side rebuilds it with GpsFix.fromMap (app/lib/tracking/gps_fix.dart:31). The tracking page and Day 9 go through it in full.

  • No overloading. One method per name; optional and named parameters replace overloads.
  • No reflection. dart:mirrors is unavailable in Flutter, which is one reason JSON is decoded by hand (or, in other projects, by code generation).
  • No data class. Equality is identity unless a class overrides == and hashCode. No class in lib/ overrides operator ==, so two User objects with the same fields are not ==.
  • No checked exceptions, no Result type. Errors are thrown; the codebase normalises them to ApiException and ChannelException at the edges.