- name
- loom-dart
- description
- Dart language expertise for idiomatic, production-quality code.
- triggers
- ["dart","flutter","pub","pubspec","pub.dev","dart test","flutter test","flutter_test","package:test","null safety","isolate","statefulwidget","statelesswidget","riverpod","provider","bloc","build_runner","freezed","json_serializable","mocktail","dart analyze","dart format"]
# Dart Language Expertise
## Overview
Idiomatic, production-grade Dart 3 and Flutter: pub packages, sound null safety, records and patterns, futures and streams, isolates, and the widget lifecycle. Assumes the reader knows the syntax. The value is in what is easy to get wrong: unawaited futures, public fields that never promote, `BuildContext` used after an `await`, futures created in `build`, isolate message copying, and a test filter that matches by substring.
## Tooling
`dart` (or `flutter` in a Flutter project) is the build tool, package manager, formatter, analyzer and test runner in one.
| Task | Command |
| --- | --- |
| New package | `dart create -t package spool` / `flutter create --template=package spool` / `flutter create app` |
| Add / remove dep | `dart pub add path` / `dart pub remove path` (`flutter pub add` in Flutter projects) |
| Add dev dep | `dart pub add dev:test` |
| Resolve | `dart pub get` (`--enforce-lockfile` in CI fails on drift from `pubspec.lock`) |
| Outdated / upgrade | `dart pub outdated` / `dart pub upgrade --major-versions` |
| Format | `dart format .` |
| Analyze | `dart analyze --fatal-infos` / `flutter analyze` |
| Apply fixes | `dart fix --apply` |
| Code generation | `dart run build_runner build --delete-conflicting-outputs` |
| Test | `dart test` / `flutter test` |
| Native binary | `dart compile exe bin/spool.dart -o spool` |
- Use `dart pub add` so `pubspec.yaml` and `pubspec.lock` stay in step. Commit `pubspec.lock` for applications.
- Monorepos (3.6+): a root `pubspec.yaml` lists `workspace: [packages/a, packages/b]` and each member declares `resolution: workspace`, giving one shared resolution and one lockfile.
- Generated files (`*.g.dart`, `*.freezed.dart`) come from `build_runner`; never edit them by hand.
```yaml
# analysis_options.yaml
include: package:lints/recommended.yaml # Flutter: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true # no implicit downcast from dynamic
strict-inference: true
strict-raw-types: true
errors:
unawaited_futures: error
linter:
rules:
- unawaited_futures
- discarded_futures
- cancel_subscriptions
- close_sinks
- avoid_print
```
The canonical gate (swap in `flutter analyze` and `flutter test` for Flutter packages):
```bash
dart format --output=none --set-exit-if-changed .
dart analyze --fatal-infos
dart test
```
## Sound Null Safety
- Types are non-nullable unless marked `T?`, and the guarantee is sound: a non-nullable static type never holds `null` at runtime.
- Type promotion after a null check applies to local variables and parameters, and (3.2+) to private `final` fields. Public fields and getters never promote, because a subclass or another library could override them. Copy to a local first.
- `!` throws `Null check operator used on a null value` at runtime. Each `!` is an unchecked claim; prefer promotion, `??`, or an early return.
- `late` defers initialization. Reading a `late` field before it is written throws `LateInitializationError`. `late final x = compute();` runs `compute` lazily on first read.
- Mark mandatory named parameters `required`. A nullable parameter checked inside the function moves the error from compile time to runtime.
```dart
class Spool {
Spool(this.path, {this.maxBytes});
final String path;
final int? maxBytes; // public: never promotes
bool isFull(int written) {
final limit = maxBytes; // a local promotes
if (limit == null) return false;
return written >= limit; // limit is int here
}
}
```
## Types, Records and Patterns
- `sealed` classes make `switch` exhaustive: adding a subtype turns every non-exhaustive switch into a compile error.
- Class modifiers state intent: `final` (no subtypes outside the library), `base` (inherit only), `interface` (implement only), `mixin class`.
- Records return several values without a class: `(int, String)`, `({int line, int column})`. Records compare by value.
- Patterns validate and destructure untyped data such as decoded JSON in one step.
- Extension types (3.3) wrap a representation type at zero runtime cost, useful for ids and units.
- Override `==` and `hashCode` together, or use a record or a generated data class.
```dart
sealed class SpoolEvent {}
final class Appended extends SpoolEvent {
Appended(this.bytes);
final int bytes;
}
final class Rotated extends SpoolEvent {
Rotated(this.segment);
final String segment;
}
String describe(SpoolEvent event) => switch (event) {
Appended(:final bytes) => 'appended $bytes bytes',
Rotated(:final segment) => 'rotated to $segment',
};
extension type SegmentId(int value) {}
Config parseConfig(Object? json) {
if (json case {'path': String path, 'maxBytes': int maxBytes}) {
return Config(path, maxBytes);
}
throw FormatException('invalid config', json);
}
```
## Errors
- `Exception` types describe expected failures that callers handle. `Error` types (`StateError`, `ArgumentError`, `RangeError`) describe programming bugs; do not catch them in normal flow.
- Catch by type with `on FormatException catch (e, st)`. A bare `catch (e)` also catches `Error`s; keep it to top-level handlers that log and report.
- `rethrow` keeps the original stack trace. When translating an error, use `Error.throwWithStackTrace(SpoolException(...), st)`.
- An error thrown inside an `async` function completes its `Future` with that error; nobody sees it unless the future is awaited or handled.
- In Flutter, route uncaught errors to reporting through `FlutterError.onError` and `PlatformDispatcher.instance.onError`.
```dart
final class SpoolException implements Exception {
SpoolException(this.message, {this.path});
final String message;
final String? path;
@override
String toString() => 'SpoolException: $message${path == null ? '' : ' ($path)'}';
}
Future<Config> loadConfig(File file) async {
try {
return parseConfig(jsonDecode(await file.readAsString()));
} on FormatException catch (e, st) {
Error.throwWithStackTrace(SpoolException(e.message, path: file.path), st);
}
}
```
## Async and Streams
- Await every `Future`, or hand it to `unawaited(...)` (from `dart:async`) so the intent is visible. The `unawaited_futures` and `discarded_futures` lints enforce this.
- `(a, b).wait` on a record of futures (3.0+) runs them concurrently and throws a `ParallelWaitError` carrying every result and error. `Future.wait` reports only the first error.
- `future.timeout(d)` stops waiting; the underlying work keeps running.
- Streams are single-subscription by default; a second `listen` throws `Bad state: Stream has already been listened to.` Use `StreamController.broadcast()` for multiple listeners.
- Cancel every `StreamSubscription` and close every `StreamController` you create (`cancel_subscriptions`, `close_sinks`).
- An `async*` generator pauses at `yield` while its subscriber is paused, which gives backpressure for free.
```dart
Future<Dashboard> load(UserId id) async {
final (profile, orders) = await (api.profile(id), api.orders(id)).wait;
return Dashboard(profile, orders);
}
Stream<Segment> segments(Directory dir) async* {
await for (final entity in dir.list()) {
if (entity is File && entity.path.endsWith('.seg')) {
yield await Segment.open(entity);
}
}
}
final subscription = watcher.events.listen(onEvent, onError: onError);
// ...
await subscription.cancel(); // otherwise the listener and its resources stay alive
```
## Isolates
- Each isolate has its own heap and event loop; there is no shared mutable memory. Messages are copied, except `TransferableTypedData` and the value returned through `Isolate.exit`.
- `Isolate.run(() => work())` (2.19+) runs one computation on a new isolate and returns its result; use it for parsing or crunching that would stall the UI or a server's event loop.
- The closure sent to an isolate carries everything it captures. Capturing `this` copies the whole object graph, and an unsendable member (a `Socket`, a `ReceivePort`, a native handle) throws `Illegal argument in isolate message`. Capture only plain data.
- Long-lived workers: `Isolate.spawn` with a `SendPort`/`ReceivePort` pair. Flutter's `compute()` wraps `Isolate.run`; on the web there are no isolates and `compute()` runs on the main thread.
```dart
Future<List<Entry>> parseLarge(Uint8List bytes) =>
Isolate.run(() => decodeEntries(bytes)); // bytes are copied into the new isolate
```
## Flutter Essentials
- Widgets are immutable configuration. `const` constructors let Flutter skip rebuilding identical subtrees.
- `setState` wraps a synchronous mutation only. Do async work first, then call `setState` with the result.
- After any `await` in a `State` method, check `mounted` (or `context.mounted` outside a `State`) before touching `context` or calling `setState`. The `use_build_context_synchronously` lint flags misses.
- Create futures and streams in `initState`, never in `build`: `build` runs often, and a new future restarts `FutureBuilder` each time.
- Dispose controllers, focus nodes, animation controllers and subscriptions in `dispose`.
- Give list items that can move a `ValueKey(item.id)` so their state follows the item.
- State management: `ValueNotifier`/`ChangeNotifier` with `ListenableBuilder` covers small apps. Follow the project's existing Provider, Riverpod or Bloc setup; do not mix them.
- Split large `build` methods into small widget classes; helper methods returning widgets rebuild with their parent and cannot be `const`.
```dart
class SpoolPage extends StatefulWidget {
const SpoolPage({super.key, required this.client});
final SpoolClient client;
@override
State<SpoolPage> createState() => _SpoolPageState();
}
class _SpoolPageState extends State<SpoolPage> {
late final Future<List<String>> _entries;
@override
void initState() {
super.initState();
_entries = widget.client.list(); // runs once per State
}
Future<void> _purge() async {
await widget.client.purge();
if (!mounted) return; // the page may be gone after the await
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Purged')));
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<String>>(
future: _entries,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
final entries = snapshot.data;
if (entries == null) return const CircularProgressIndicator();
return ListView.builder(
itemCount: entries.length,
itemBuilder: (context, i) => ListTile(title: Text(entries[i]), onTap: _purge),
);
},
);
}
}
```
## Testing
`package:test` provides `test`, `group`, `setUp`, `tearDown`, `addTearDown` and the matcher library; `flutter_test` re-exports it and adds `testWidgets` with a `WidgetTester`.
```dart
import 'package:mocktail/mocktail.dart';
import 'package:spool/spool.dart';
import 'package:test/test.dart';
class MockClient extends Mock implements SpoolClient {}
void main() {
group('Spool', () {
late MockClient client;
setUp(() => client = MockClient());
test('rotates when the limit is reached', () async {
when(() => client.rotate()).thenAnswer((_) async {});
final spool = Spool(client, maxBytes: 4);
await spool.append([1, 2, 3, 4, 5]);
expect(spool.segments, hasLength(2));
verify(() => client.rotate()).called(1);
});
test('rejects an empty path', () {
expect(() => Spool.open(''), throwsA(isA<ArgumentError>()));
});
test('emits appended then rotated', () async {
when(() => client.rotate()).thenAnswer((_) async {});
final spool = Spool(client, maxBytes: 1);
final events = expectLater(spool.events, emitsInOrder([isA<Appended>(), isA<Rotated>()]));
await spool.append([1, 2]);
await events;
});
});
}
```
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('purge shows a confirmation', (tester) async {
await tester.pumpWidget(MaterialApp(home: Scaffold(body: SpoolPage(client: FakeClient()))));
await tester.pump(); // resolve the entries future
await tester.tap(find.text('first'));
await tester.pump();
expect(find.text('Purged'), findsOneWidget);
});
}
```
- Async tests return their `Future`; the runner waits for it. `expectLater` returns a future to await when the assertion itself is asynchronous.
- `pumpAndSettle` pumps until no frames are scheduled and times out on endless animations such as a spinning `CircularProgressIndicator`; use `pump(duration)` there.
- Control time with `package:fake_async` or an injected `Clock` from `package:clock`.
- Golden tests (`matchesGoldenFile`, `flutter test --update-goldens`) render fonts differently across operating systems; generate and compare them on one platform.
- Useful flags: `--name` (regex), `--plain-name` (substring), `-t`/`-x` for tags, `-p chrome`, `--reporter expanded`, `--coverage=coverage` (`--coverage` for Flutter).
## Loom Test Runner Adapter
**Adapters.** `dart-test` and `flutter-test`. A directory containing `pubspec.yaml` is a `dart` package; loom picks `flutter-test` when `pubspec.yaml` depends on `flutter`, else `dart-test`. `loom project detect` prints the kind, runner and skill per package; confirm that a pure Dart package reports `dart-test`, since `flutter test` needs the Flutter SDK.
**Single-test command.** Loom runs each contract with the package directory as the working directory:
```bash
dart test {file} --plain-name '{test}'
flutter test {file} --plain-name '{test}'
```
View on GitHub