Dart Language skill for Claude Code
Sealed states, exhaustive switches, no bang operators. Claude checks the SDK floor in pubspec.yaml before using records, dot shorthands, or other gated syntax, then models domain states as sealed hierarchies matched by exhaustive switch expressions. It replaces bang operators with flow analysis and patterns, moves CPU-heavy work into Isolate.run, enables strict analyzer modes and future lints, and proves lower bounds with pub downgrade.
- name
dart-language - size269 lines · ≈2,500 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes idiomatic Dart 3 independent of Flutter UI — sound null safety without the bang operator, records and destructuring, switch expressions with exhaustive patterns over sealed class hierarchies, class modifiers (final, base, interface), extension types, dot shorthands, async/await with Streams, isolates via Isolate.run for CPU work, and package layout with pubspec, pub workspaces, analysis_options lints, and dart test. Use when writing Dart libraries, CLIs, or server code, modelling state with sealed classes, fixing null-safety or type errors, choosing between isolates and async, or setting up a Dart package for publishing. Try asking
- Refactor this Dart state handling into a sealed class with an exhaustive switch
- Parse this JSON in Dart without casts or dynamic calls
- Set up a Dart package with strict analysis, tests, and publishing checks
Use it when
- Writing Dart libraries, command-line tools, or server code
- Modelling states or results with sealed classes and patterns
- Fixing null-safety, promotion, or covariance type errors
- Deciding whether work belongs in async code or an isolate
Skip it when
- The problem is Flutter layout, widgets, or state management — use flutter-widgets
- Driving a whole app on real devices in end-to-end tests — use mobile-e2e-testing
How to check the result
dart analyze --fatal-infos, dart format --set-exit-if-changed and dart test pass, and adding a dummy subtype to a sealed class breaks every switch that must handle it.
The common mistake it prevents
Silencing null-safety errors with ! or late instead of restructuring, which turns compile-time guarantees back into runtime crashes.
Done well means: no ! without a proven invariant, domain states are sealed hierarchies
matched by exhaustive switch expressions, data has value semantics where it should,
CPU-heavy work runs off the main isolate, and dart analyze --fatal-infos, dart format,
and dart test are clean on the SDK floor declared in pubspec.yaml.
Know the language version
Features are gated by the SDK lower bound in pubspec.yaml (environment: sdk: ^3.x.0),
not by the installed SDK. Check it before using newer syntax.
| Feature | Min SDK |
|---|---|
| Records, patterns, switch expressions, sealed/class modifiers | 3.0 |
| Extension types | 3.3 |
Digit separators (1_000_000), pub workspaces | 3.6 |
Wildcard variables (_ does not bind; repeatable) | 3.7 |
Null-aware collection elements ([?maybeItem], {key: ?value}) | 3.8 |
Dot shorthands (.center, .circular(8), .zero when type is known) | 3.10 |
Private named parameters ({this._field}) | 3.12 |
Macros were cancelled; use build_runner code generation (json_serializable,
freezed) or plain code. Primary constructors are experimental in 3.12; do not use them in
shared code until they are stable in the declared SDK.
Null safety
- Type it nullable only if absence is meaningful. Prefer non-nullable fields initialized in
the constructor or marked
late finalwhen set exactly once before use. - Replace
!with flow analysis:
// Fields do not promote unless private and final (3.2+); copy to a local
final user = this.user;
if (user == null) return const Unauthenticated();
return Authenticated(user.name); // promoted to non-null
// Or use a pattern
if (json['age'] case final int age when age >= 0) { ... }
latewithout an initializer moves the null check to runtime (LateInitializationError). Use it for dependency-injected fields set ininitState-like hooks, not to silence errors.?.,??,??=for defaults;?..cascades on nullable receivers.
Records and patterns
(double lat, double lng) parse(String s) {
final [a, b] = s.split(','); // throws if not exactly 2 parts
return (double.parse(a), double.parse(b));
}
final (lat, lng) = parse('51.5,-0.12');
// Named fields
({int total, int failed}) summarize(List<Result> rs) =>
(total: rs.length, failed: rs.where((r) => !r.ok).length);
final (:total, :failed) = summarize(results);
- Records have structural equality and are ideal for multiple returns and map keys. For domain types that cross module boundaries, prefer a named class: records cannot carry methods or documentation.
- Destructure JSON safely with map patterns instead of casts:
switch (json) {
case {'id': String id, 'tags': List<Object?> tags}:
return Item(id, tags.whereType<String>().toList());
default:
throw FormatException('Invalid item: $json');
}
Sealed hierarchies and exhaustive switch
sealed class PaymentState {}
final class Pending extends PaymentState {}
final class Paid extends PaymentState {
Paid(this.receiptId);
final String receiptId;
}
final class Failed extends PaymentState {
Failed(this.reason, {this.retryable = false});
final String reason;
final bool retryable;
}
String label(PaymentState s) => switch (s) {
Pending() => 'Processing…',
Paid(:final receiptId) => 'Paid ($receiptId)',
Failed(retryable: true) => 'Failed, retry available',
Failed(:final reason) => 'Failed: $reason',
};
- No
defaultbranch on sealed types: adding a subtype must break the build in every switch. - Subtypes of a
sealedclass must live in the same library (file orpartfiles). - Guards:
case Paid(:final receiptId) when receiptId.isEmpty:.
Class modifiers
| Modifier | Outside the library you can… |
|---|---|
| (none) | extend, implement, construct |
final class | construct only — the default for value/data classes in public APIs |
base class | extend, not implement (guarantees your private members exist) |
interface class | implement, not extend |
abstract interface class | implement only; a pure interface |
sealed class | nothing — closed set of subtypes for exhaustiveness |
mixin class | use as both mixin and class |
Make public classes final unless extension is a designed feature; loosening later is
non-breaking, tightening is breaking.
Value equality and immutability
- Dart classes compare by identity. For value objects override
==andhashCode(Object.hash(a, b)), or generate withfreezed/equatable. constconstructors for compile-time constants; mark all fieldsfinal.List.unmodifiable(xs)when exposing collections;final List<T>only freezes the reference.
Extension types and extensions
extension type const UserId(String value) {
UserId.parse(String raw) : this(raw.trim());
bool get isGuest => value.startsWith('guest_');
}
extension DurationFormat on Duration {
String get hms => '${inHours}h ${inMinutes.remainder(60)}m';
}
- Extension types are zero-cost compile-time wrappers: use them to stop mixing IDs. They
are erased at runtime, so
is UserIdchecks against the underlyingString— do not rely on them for runtime validation of untrusted data. - Extension methods are resolved statically; they do not work through
dynamic.
Async, Streams, and isolates
Future<List<Item>> loadAll(List<Uri> uris) =>
Future.wait(uris.map(fetchItem), eagerError: true);
Stream<int> ticks(Duration every) async* {
var i = 0;
while (true) {
await Future<void>.delayed(every);
yield i++;
}
}
final sub = ticks(const Duration(seconds: 1)).listen(print);
await sub.cancel(); // always cancel subscriptions
| Work | Use |
|---|---|
| I/O (HTTP, files, DB) | async/await; it does not block the event loop |
| Several independent I/O calls | Future.wait or record (a, b).wait |
| CPU work > ~16 ms (parsing big JSON, crypto, image ops) | await Isolate.run(() => heavy(input)) (Flutter: compute) |
| Long-lived background worker with messages | Isolate.spawn + ReceivePort/SendPort |
| Event sequences | Stream (single-subscription by default; .asBroadcastStream() for many listeners) |
- Unawaited futures swallow errors: enable the
unawaited_futuresanddiscarded_futureslints; wrap intentional fire-and-forget inunawaited(...)fromdart:async. - Isolates share no memory; arguments are copied (except immutable and transferable
data). Closures sent to
Isolate.runmust not capture non-sendable objects (open sockets,ReceivePort). StreamControllermust be closed; useonCancelto release resources.- Wrap timeouts explicitly:
fetch().timeout(const Duration(seconds: 10)).
Errors
- Throw
Exceptionsubtypes for recoverable conditions callers should catch;Errorsubtypes (ArgumentError,StateError) for programming bugs — never catch those in normal flow. on FormatException catch (e, st)to catch specific types; barecatchhides bugs.- Rethrow with
rethrow(keeps the stack trace), notthrow e. - Result types (
sealed class Result<T>withOk/Err) are a good fit at domain boundaries; do not wrap every function.
Packages and tooling
# pubspec.yaml
name: billing_core
environment:
sdk: ^3.8.0
dependencies:
http: ^1.2.0
dev_dependencies:
lints: ^6.0.0
test: ^1.25.0
# analysis_options.yaml
include: package:lints/recommended.yaml # flutter_lints in Flutter apps
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
rules:
- unawaited_futures
- discarded_futures
- prefer_final_locals
- avoid_dynamic_calls
dart create -t package billing_core # or -t console, -t server-shelf
dart pub add http && dart pub add dev:test
dart analyze --fatal-infos
dart format --output=none --set-exit-if-changed .
dart fix --dry-run && dart fix --apply # migrate deprecated APIs and lint fixes
dart test --coverage=coverage
dart pub outdated
dart pub publish --dry-run
dart compile exe bin/cli.dart -o build/cli # AOT native binary
- Layout: public API in
lib/<package>.dartexporting fromlib/src/; anything underlib/srcis private by convention. - Monorepos: pub workspaces (3.6+) — root
pubspec.yamllistsworkspace:members and each member setsresolution: workspace; one shared lockfile. - The formatter changed style in 3.7 (tall style, trailing commas managed by the formatter); formatting output depends on the package’s language version, so do not mix SDK floors in CI and local runs.
Gotchas
List<int>is not aList<num>for writes at runtime: covariant generics compile but can throwTypeErroron insert.intis 64-bit on native but JS numbers on the web (precision loss above 2^53).==on lists compares identity; useconst ListEquality().equalsfrompackage:collectionor records.forEachwith anasynccallback does not await; usefor (final x in xs) await f(x);orFuture.wait.jsonDecodereturnsdynamic; validate with patterns before use and enableavoid_dynamic_calls.- A
switchstatement on a non-sealed type still needsdefaultto be exhaustive. DateTime.now()is local time; store and comparetoUtc()values.
Verification
dart analyze --fatal-infosreports nothing.dart format --set-exit-if-changed .passes.dart testpasses; add a test per sealed subtype and per error branch.- Temporarily add a new subtype to each sealed class touched: every
switchover it must fail to compile, proving exhaustiveness is doing its job. Remove it afterwards. - For packages:
dart pub publish --dry-runand a downgrade check (dart pub downgrade && dart test) to prove the lower bounds are real.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.