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.

  • namedart-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.

~/.claude/skills/dart-language/SKILL.md

--- name: dart-language description: Writes idiomatic Dart 3 independent of Flutter UI — sound null safety without the bang operator, records and destructuring, switch… license: MIT ---

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.

FeatureMin SDK
Records, patterns, switch expressions, sealed/class modifiers3.0
Extension types3.3
Digit separators (1_000_000), pub workspaces3.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 final when 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) { ... }
  • late without an initializer moves the null check to runtime (LateInitializationError). Use it for dependency-injected fields set in initState-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 default branch on sealed types: adding a subtype must break the build in every switch.
  • Subtypes of a sealed class must live in the same library (file or part files).
  • Guards: case Paid(:final receiptId) when receiptId.isEmpty:.

Class modifiers

ModifierOutside the library you can…
(none)extend, implement, construct
final classconstruct only — the default for value/data classes in public APIs
base classextend, not implement (guarantees your private members exist)
interface classimplement, not extend
abstract interface classimplement only; a pure interface
sealed classnothing — closed set of subtypes for exhaustiveness
mixin classuse 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 == and hashCode (Object.hash(a, b)), or generate with freezed/equatable.
  • const constructors for compile-time constants; mark all fields final.
  • 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 UserId checks against the underlying String — 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
WorkUse
I/O (HTTP, files, DB)async/await; it does not block the event loop
Several independent I/O callsFuture.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 messagesIsolate.spawn + ReceivePort/SendPort
Event sequencesStream (single-subscription by default; .asBroadcastStream() for many listeners)
  • Unawaited futures swallow errors: enable the unawaited_futures and discarded_futures lints; wrap intentional fire-and-forget in unawaited(...) from dart:async.
  • Isolates share no memory; arguments are copied (except immutable and transferable data). Closures sent to Isolate.run must not capture non-sendable objects (open sockets, ReceivePort).
  • StreamController must be closed; use onCancel to release resources.
  • Wrap timeouts explicitly: fetch().timeout(const Duration(seconds: 10)).

Errors

  • Throw Exception subtypes for recoverable conditions callers should catch; Error subtypes (ArgumentError, StateError) for programming bugs — never catch those in normal flow.
  • on FormatException catch (e, st) to catch specific types; bare catch hides bugs.
  • Rethrow with rethrow (keeps the stack trace), not throw e.
  • Result types (sealed class Result<T> with Ok/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>.dart exporting from lib/src/; anything under lib/src is private by convention.
  • Monorepos: pub workspaces (3.6+) — root pubspec.yaml lists workspace: members and each member sets resolution: 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 a List<num> for writes at runtime: covariant generics compile but can throw TypeError on insert.
  • int is 64-bit on native but JS numbers on the web (precision loss above 2^53).
  • == on lists compares identity; use const ListEquality().equals from package:collection or records.
  • forEach with an async callback does not await; use for (final x in xs) await f(x); or Future.wait.
  • jsonDecode returns dynamic; validate with patterns before use and enable avoid_dynamic_calls.
  • A switch statement on a non-sealed type still needs default to be exhaustive.
  • DateTime.now() is local time; store and compare toUtc() values.

Verification

  1. dart analyze --fatal-infos reports nothing.
  2. dart format --set-exit-if-changed . passes.
  3. dart test passes; add a test per sealed subtype and per error branch.
  4. Temporarily add a new subtype to each sealed class touched: every switch over it must fail to compile, proving exhaustiveness is doing its job. Remove it afterwards.
  5. For packages: dart pub publish --dry-run and 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.

esc