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.
Rosetta table
Section titled “Rosetta table”| 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.
Files, libraries and privacy
Section titled “Files, libraries and privacy”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:1import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode;// app/lib/features/auth/auth_notifier.dart:12export '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, final, const, late
Section titled “var, final, const, late”varandfinalwork like Kotlinvarandval. Types are inferred.constmeans compile-time constant, and it is stronger than in TS or Kotlin: aconstobject is built by the compiler and canonicalised, so two identicalconstexpressions are the same instance.latedefers 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-27class 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-48static 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-110late 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.
Null safety
Section titled “Null safety”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-134final 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-22String 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-70factory 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.
Constructors
Section titled “Constructors”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-94static 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).
Classes, interfaces and class modifiers
Section titled “Classes, interfaces and class modifiers”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-12abstract 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-5abstract 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-22enum 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.
Sealed classes and patterns
Section titled “Sealed classes and patterns”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, QueueErrorThe 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’sis QueueIdle ->.||combines patterns, like Elixir’s multiple heads or Kotlin’s comma inwhen.QueueWaiting(:final since)matches the type and binds itssincefield to a local. It is short forQueueWaiting(since: final since). This is the closest Dart gets to Elixir’s%QueueWaiting{since: since}.- No
_default is needed, and if someone adds a seventhQueueStatesubclass, 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
Section titled “Records”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-60Future<(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-35final (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 as values
Section titled “Functions as values”Functions are first-class, and typedef names a function type:
// app/lib/core/clock.dart:5-7typedef 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-19const 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-40final 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-78ref.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.
Extensions and mixins
Section titled “Extensions and mixins”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-313extension 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.
Futures and async/await
Section titled “Futures and async/await”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-31Future<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
awaitis 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.microtaskschedules work after the current synchronous code finishes. The queue screen uses it to start matchmaking right afterinitState, 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 aFuture, stores it, and clears the field infinally. That is the JS(async () => { ... })()idiom, used as a one-at-a-time lock.
Streams
Section titled “Streams”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
StreamControllerin 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-45final 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).
Isolates: Dart’s processes
Section titled “Isolates: Dart’s processes”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.
What you will not find
Section titled “What you will not find”- No overloading. One method per name; optional and named parameters replace overloads.
- No reflection.
dart:mirrorsis 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==andhashCode. No class inlib/overridesoperator ==, so twoUserobjects with the same fields are not==. - No checked exceptions, no
Resulttype. Errors are thrown; the codebase normalises them toApiExceptionandChannelExceptionat the edges.
Where to go next
Section titled “Where to go next”- The Flutter mental model for widgets, elements and
build(). - Riverpod as used here for providers and notifiers.
- Day 1 reads
lib/coreandmain.dartwith this page open. - The glossary when a name in the code means nothing to you.