dart_modernize 0.11.0 copy "dart_modernize: ^0.11.0" to clipboard
dart_modernize: ^0.11.0 copied to clipboard

A CLI tool that modernizes Dart and Flutter codebases by applying idiomatic patterns, enforcing conventions, and automating tedious upgrades.

โšก dart_modernize #

A type-aware codemod that rewrites Dart and Flutter projects to use modern syntax wherever it is safe.

pub package sdk license style


dart_modernize

It resolves the project with full type information and applies each rewrite only where it provably keeps the same behavior, leaving the code shorter and more idiomatic.


๐Ÿ” Before and after #

Before After
final Color c = Color.blue;

Button(
  style: ButtonStyle.flat,
  onTap: Handler.empty(),
);

class Point {
  Point(int x, int y)
      : _x = x,
        _y = y;
  final int _x;
  final int _y;
}
final c = Color.blue;

Button(
  style: .flat,
  onTap: .empty(),
);

class Point(int _x, int _y);
const Set<Permission> allowed = {
  Permission.camera,
  Permission.microphone,
  Permission.storage,
};
const allowed = <Permission>{
  .camera,
  .microphone,
  .storage,
};
String label;
switch (status) {
  case Status.active:
    label = 'on';
    break;
  default:
    label = 'off';
}
final label = switch (status) {
  .active => 'on',
  _ => 'off',
};
final p = Paint();
p.color = accent;
p.strokeWidth = 2.0;

final tags = [
  'base',
  if (extra != null) extra,
];
final p = Paint()
  ..color = accent
  ..strokeWidth = 2.0;

final tags = ['base', ?extra];

โš™๏ธ What it does #

Twenty-two passes, in five families. Each one can be turned on or off, and each skips any code where the rewrite is not provably safe.

They all run by default except two: sort members (--sort-members), which only moves code but produces big diffs, and collection elements (--collection-elements), which turns a run of statements into one literal.

Feature Description
Dot shorthands Collapses ClassName.member and ClassName(...) to .member and .new(...) wherever the context type makes the target unambiguous: arguments, return positions, assignments, equality checks, and collection literals, including the head of a selector chain (DateTime.now().toUtc() becomes .now().toUtc()).
Switch expressions Rewrites eligible statement switches as switch expressions with modern pattern syntax: fall-through cases become || patterns and default becomes _.
Expression bodies Turns single-return block bodies into concise => bodies for functions, methods, getters, and closures.
String interpolation Rewrites 'a ' + b + ' c' concatenation chains into 'a $b c' interpolation.
Cascades Collapses sequential member writes on a fresh local into a .. cascade; drops the local when unused after the run.
Inline return Inlines a local that is immediately returned and used nowhere else: final x = expr; return x; becomes return expr;.
Final locals Replaces var with final on local variables, and on for-in loop variables, that are never reassigned, incremented, or compound-assigned.
Prefer inferred types Drops a redundant type annotation when the initializer already has exactly that type and that type is obvious from the initializer (locals, top-level consts, and final/const fields), and moves the type arguments onto a bare collection literal (List<int> x = [] becomes var x = <int>[]).
Null-aware elements Folds if (x != null) x inside a collection into the null-aware element ?x.
Null-aware spread Folds if (l != null) ...l into the null-aware spread ...?l.
Null-aware conditionals Collapses x == null ? null : x[i] to x?[i] and x != null ? x.foo : d to x?.foo ?? d. Covers only the forms dart fix leaves behind.
Destructure for-in Moves a for-in variable's field reads into an object pattern in the loop header: a loop over map.entries reading .key and .value becomes for (final MapEntry(:key, :value) in map.entries).
Destructure locals Collapses a local that only exists to read fields off it into one destructuring declaration: final p = get(); final x = p.x; final y = p.y; becomes final Point(:x, :y) = get();.
Private named parameters Folds constructor boilerplate into the private named parameter form (this._field).
Primary constructors Promotes eligible classes to the primary constructor form, including const ones, only when it is provably safe.
Super parameters Forwards constructor parameters straight to the superclass with super.x.
Organize imports Sorts, groups, and prunes unused directives.
Collection elements Folds a run of add/addAll calls on a freshly declared empty literal into one literal with collection-if and collection-for elements. Off by default (opt in with --collection-elements).
Sort members Reorders members into the canonical order. Off by default (opt in with --sort-members); it only moves code but can produce a large diff.
Sort constructors first Lifts every constructor ahead of the other members in each class, enum, mixin, and extension type.
Fix all Applies the same bulk fixes as dart fix, in the same pass.
Abstract final classes Adds abstract final to classes that expose only static members and are never instantiated, extended, implemented, or mixed in anywhere in the project.

Every edit is type checked before it lands. The tool does not change the resolved type, the targeted element, the evaluation count, or the runtime behavior of an expression. If it cannot prove a change is safe, it leaves the code as is.


๐Ÿ”€ Rewrites vs reordering #

No pass changes what your program does. But they split into two groups that feel very different in review, which is worth knowing before you read a diff.

Syntax rewrites. Twenty of the twenty-two passes rewrite code in place. Each one edits what it touches and leaves the rest alone, so the diff only covers the lines that actually changed.

Layout only. Two passes move code without editing it: sort members and sort constructors first. They reorder declarations and change nothing else, so every line in the diff is a line that was cut from one place and pasted, byte for byte, into another.

Reordering is safe because Dart does not resolve declarations by their position in the file. A method can call one declared below it, and a class can reference a top-level function defined later, so moving a declaration cannot change what any name resolves to.

The one thing that genuinely does depend on position is field initialization order, and it is preserved. Fields move as a group into their slot in the canonical order, but never past one another: within that group they stay in the exact order they were declared, public and private alike. So a field whose initializer reads another field still runs after the one it depends on.

The cost is review, not correctness. A file can turn into hundreds of moved lines, git blame points at the move, and a real change is easy to miss in the noise. That is why sort members is off by default. Run it on its own commit:

dart_modernize --only sort-members,sort-constructors-first

๐Ÿงฉ Transformations #

Each pass below has a minimal before/after and the rule that decides when it is skipped.

Type-aware syntax #

Passes that rely on full type resolution to guarantee the rewrite resolves to the exact same element.

Dot shorthands: collapses redundant type names (enum values, static members, named constructors, and unnamed constructors (.new)) wherever the context type is unambiguous: arguments, return positions (including a factory constructor's), assignments, equality checks, collection elements, the head of a selector chain, explicitly-typed generic arguments, factory closures, and object/record pattern fields.

// before
Service create() => Service();
Widget child(Event e) => dispatch(Event());
visibility = Visibility.hidden;
if (mode == Mode.fast) tick();

// after
Service create() => .new();
Widget child(Event e) => dispatch(.new());
visibility = .hidden;
if (mode == .fast) tick();

In a typed declaration whose type the initializer makes obvious, the type is dropped instead (see prefer inferred types); otherwise the annotation stays and supplies the context, so final Color c = Color.blue becomes final Color c = .blue.

The head of a selector chain collapses too. The context type of the whole chain flows to the leading type name, so a named constructor, static method, or static getter that begins a .method(...), .getter, [index], or ! chain loses its type name while the rest of the chain stays:

// before
Duration remaining(DateTime expiry) => expiry.difference(DateTime.now().toUtc());
DateTime? parse(String s) => DateTime.tryParse(s)?.toUtc();
Color first() => Color.values.first;

// after
Duration remaining(DateTime expiry) => expiry.difference(.now().toUtc());
DateTime? parse(String s) => .tryParse(s)?.toUtc();
Color first() => .values.first;

A generic call pins its type from an explicit <...> rather than its arguments, so the argument then has a context, and a factory closure takes its context from the function type it is written against. Together these collapse the common service-locator / dependency-injection pattern:

// before
sl
  ..registerSingleton<CrashReporter>(crashReporter ?? CrashReporter())
  ..registerLazySingleton<ThemeCubit>(() => ThemeCubit(storage: sl()));

// after
sl
  ..registerSingleton<CrashReporter>(crashReporter ?? .new())
  ..registerLazySingleton<ThemeCubit>(() => .new(storage: sl()));

Both are skipped when the type is still inferred from that very argument or closure (no explicit <...>), since collapsing would leave nothing to infer it from.

A factory constructor's body returns the class's own type, so a return (or =>) that builds it collapses too:

// before
factory AuthTokens.fromJson(Map<String, dynamic> json) {
  return AuthTokens(token: json['token'] as String);
}

// after
factory AuthTokens.fromJson(Map<String, dynamic> json) {
  return .new(token: json['token'] as String);
}

A constant inside an object or record pattern field matches that field, so it collapses against the field's type:

// before
final label = switch (exception) {
  NetworkException(kind: NetworkFailureKind.timeout) => 'timed out',
  _ => 'unknown',
};

// after
final label = switch (exception) {
  NetworkException(kind: .timeout) => 'timed out',
  _ => 'unknown',
};

In collection literals the element type flows down to each element, and an untyped literal is given an explicit type so the shorthand is well defined:

// before
final routes = [Route(home), Route(settings)];
List<Widget> build() => [Widget(a: a, b: b), Widget(a: 'genial')];

// after
final routes = <Route>[.new(home), .new(settings)];
List<Widget> build() => [.new(a: a, b: b), .new(a: 'genial')];

Refuses to apply when the context type is dynamic, Object, an inferred var, or a type variable, anywhere the shortened form would not resolve to the exact same element.

Record fields collapse too. Each field takes its context from the matching field of the record's type (positional by index, named by name), and an untyped list of records has its inferred element type hoisted so the field shorthands resolve:

// before
final options = [
  (StockReadingType.opening, 'Opening', Icons.sunny),
  (StockReadingType.closing, 'Closing', Icons.night),
];

// after
final options = <(StockReadingType, String, IconData)>[
  (.opening, 'Opening', Icons.sunny),
  (.closing, 'Closing', Icons.night),
];

The record element type is hoisted only when every field is precise; a field typed dynamic, Object, Null, or an unresolved type variable leaves the record untouched.

Switch expressions: rewrites an eligible statement switch as a switch expression with modern pattern syntax: fall-through cases collapse to || patterns, default becomes _, and a throw stays inline.

// before
String token;
switch (charCode) {
  case slash:
  case star:
    token = operatorToken(charCode);
    break;
  case comma:
    token = punctuationToken(charCode);
    break;
  default:
    throw FormatException('Invalid');
}

// after
final token = switch (charCode) {
  slash || star => operatorToken(charCode),
  comma => punctuationToken(charCode),
  _ => throw FormatException('Invalid'),
};

Also handles the return-per-case form, producing return switch (โ€ฆ) { โ€ฆ };. Left untouched when an arm runs more than one statement, branches assign different targets, breaks or continues to a label, has side effects, or is not exhaustive: anything where the expression form would change behavior.

Concise expressions #

Shorter bodies, strings, builder sequences, and type annotations, without changing any value.

Expression bodies: turns a single-return block body into a => body for functions, methods, getters, and closures.

// before
int square(int x) {
  return x * x;
}

// after
int square(int x) => x * x;

Kept as a block when it holds more than one statement, or a comment the arrow form would silently drop.

String interpolation: rewrites + concatenation chains into interpolation.

// before
String greet(String name) => 'Hello, ' + name + '!';
String row(String a, String b) => '| ' + a + ' | ' + b + ' |';

// after
String greet(String name) => 'Hello, $name!';
String row(String a, String b) => '| $a | $b |';

Only when every piece is a side-effect-free String. Arithmetic + and method-call operands are left exactly as written.

Cascades: collapses sequential member writes and calls on a freshly declared local into a single cascade. When the local is unused after the run it is dropped entirely.

// before: local kept
final paint = Paint();
paint.color = accent;
paint.strokeWidth = 2.0;
paint.style = PaintingStyle.stroke;

// after: local kept
final paint = Paint()
  ..color = accent
  ..strokeWidth = 2.0
  ..style = PaintingStyle.stroke;

// before: local unused after run
final reporter = Reporter(source);
reporter.error('not found');
reporter.errorHint('check spelling');

// after: dropped to a bare statement cascade
Reporter(source)
  ..error('not found')
  ..errorHint('check spelling');

Applies only when the target is not reassigned, read between writes, or passed as an argument within the run, and no right-hand side reads the target.

Inline return: inlines a local whose only remaining use is an immediate bare return.

// before
final value = compute();
return value;

// after
return compute();

This also handles the intermediate form produced by the cascades pass in a subsequent run:

// before (after cascades)
var conn = Connection(host)
  ..open()
  ..authenticate(token);
return conn;

// after
return Connection(host)
  ..open()
  ..authenticate(token);

Skipped when the local has more than one use, carries a comment, is declared alongside other variables in one statement, or the return is not an immediate bare reference to the local.

Final locals: replaces var with final on local variables that are never reassigned anywhere in the enclosing function body, and on for-in loop variables that are never reassigned in the loop.

// before
var name = user.displayName;
var multiplier = getMultiplier();
print(name);
return multiplier * rate;

// after
final name = user.displayName;
final multiplier = getMultiplier();
print(name);
return multiplier * rate;
// before
for (var item in items) {
  render(item);
}

// after
for (final item in items) {
  render(item);
}

Skipped when the variable is written to anywhere in the enclosing body, including inside a closure: assigned, compound-assigned (+=), or incremented (++). A classic for (var i = 0; i < n; i++) counter is left alone.

The for-in half is what prefer_final_in_for_each flags, so fix all applies it too, but only where a project enables that lint. This pass does it everywhere, and the two never clash because this one runs first.

Prefer inferred types: drops a type annotation the initializer already implies, and moves the type arguments onto a bare collection literal.

// before
final String name = 'guest';
const int retries = 3;
final List<String> tags = [];
final Logger _log = Logger();
final Client _client = .new();

// after
final name = 'guest';
const retries = 3;
final tags = <String>[];
final _log = Logger();
final _client = Client();

The type is only dropped when the initializer already says it plainly: a literal, a typed collection literal, a written-out constructor call, or a cast. A method call, a property access or a bare identifier is not plain enough, so those keep their annotation. This matches the analyzer's omit_obvious_* and specify_nonobvious_* rules, so dart fix will not put the type back.

Applies to locals, top-level consts, and final/const fields with an initializer. Mutable fields and non-const top-level variables are left alone.

Dropping the type wins over the .new() shorthand: final Foo _x = Foo() becomes final _x = Foo(), and a field already written final Foo _x = .new() is expanded back to final _x = Foo().

Null-aware collections #

The Dart 3.8 null-aware collection syntax, applied only when the rewrite preserves single evaluation.

Null-aware elements: folds a null guard inside a collection into ?x.

// before
List<int> build(int? a) => [if (a != null) a];

// after
List<int> build(int? a) => [?a];

Null-aware spread: folds a guarded spread into ...?l.

// before
List<int> build(List<int>? extra) => [0, if (extra != null) ...extra];

// after
List<int> build(List<int>? extra) => [0, ...?extra];

?expr evaluates the operand once, where the old if/value form evaluated it twice. So these apply only to a stable, side-effect-free reference (a local or const). Getters, method calls, and index lookups are left alone.

Null-aware conditionals: collapses a null-check conditional into ?[] and ??.

// before
int? first(List<int>? xs) => xs == null ? null : xs[0];
String label(Box? box, String fallback) => box != null ? box.name : fallback;

// after
int? first(List<int>? xs) => xs?[0];
String label(Box? box, String fallback) => box?.name ?? fallback;

Only these two shapes. The lints prefer_if_null_operators and prefer_null_aware_operators ship in package:lints/recommended.yaml, so fix all already handles x == null ? d : x and x == null ? null : x.foo. What is left is the index form, which no lint covers, and a chain with a real fallback instead of null.

The fallback form needs the chain's type to be non-nullable. If box.name could itself be null, box != null ? box.name : d gives null where box?.name ?? d gives d. Both forms also need the tested expression to be a plain local or parameter.

Destructure for-in: moves a loop variable's field reads into an object pattern in the header.

// before
for (final entry in scores.entries) {
  print('${entry.key} = ${entry.value}');
}

// after
for (final MapEntry(:key, :value) in scores.entries) {
  print('$key = $value');
}

Only the fields the body reads are moved, and each binding keeps the field's own name. Skipped when the loop variable is used whole, reassigned, or has a method called on it, or when a bound name is already taken in the enclosing function.

Fields must be final and non-late. The pattern reads every field once per iteration, where the body read them where they appeared, so a computed getter could end up running when it did not before. Positional record fields (pair.$1) have no name to bind, so they are skipped.

Destructure locals: collapses a local that only exists to read fields off it.

// before
final result = computePair();
final a = result.$1;
final b = result.$2;

// after
final (a, b) = computePair();
// before
final p = getPoint();
final x = p.x;
final y = p.y;

// after
final Point(:x, :y) = getPoint();

The names come from the locals you already wrote, so nothing is invented. One named differently from its field keeps its own name: final first = p.x gives Point(x: first).

Skipped when the intermediate is used for anything else, when another statement interrupts the run, or when a statement carries a comment the rewrite would drop. Fields must be final and non-late, for the same reason as destructure for-in: the reads all move up to the declaration. Records with named fields are skipped.

Collection elements (off by default): folds a step-by-step build into one literal.

// before
final items = <Widget>[];
items.add(header);
if (showBody) items.add(body);
for (final s in sections) items.add(s);

// after
final items = <Widget>[
  header,
  if (showBody) body,
  for (final s in sections) s,
];

Opt in with --collection-elements. This one turns a run of statements into a single expression, a bigger structural change than any other pass makes, which is why it is off by default.

The literal has to start out empty, and the statements after it have to be add/addAll calls, or an else-less if or a for around one. Anything else ends the run, so the part before it is folded and the rest stays. Reading the collection while building it (items.add(items.length)) also ends the run, since a literal cannot express that.

It runs before cascades on purpose. Both passes want the same statements, and cascades would otherwise fold them into <Widget>[]..add(header)..add(body) first, leaving nothing to collapse into a literal.

Constructor shorthands #

Folds constructor boilerplate into the shorthands the language now provides.

Private named parameters: folds the "public param, private field" boilerplate into a private named parameter.

// before
class User {
  final String _name;
  User({required String name}) : _name = name;
}

// after
class User {
  final String _name;
  User({required this._name});
}

Left alone when the parameter is transformed, renamed, or reused elsewhere in the initializer list.

Primary constructors: promotes a class whose only job is to bind constructor parameters to fields.

// before
class Point {
  final int x;
  final int y;
  Point(this.x, this.y);
}

// after
class Point(final int x, final int y);

A const constructor promotes to a constant primary constructor, with the modifier between class and the name:

// before
class Origin {
  final int x;
  const Origin(this.x);
}

// after
class const Origin(final int x);

Primary constructors went stable in Dart 3.13, which is also this tool's SDK floor, so nothing extra is needed to opt in. The pass double-checks the resolved language version anyway and stays a no-op if it is somehow not met.

Skipped when the class has another constructor, a constructor body, an initializer list, or a non-this. parameter. A named primary constructor (class Point.origin(...)) is valid 3.13 syntax but is never produced, since the pass only promotes an unnamed constructor.

Super parameters: forwards a constructor parameter straight to the superclass.

// before
class MyWidget extends Widget {
  const MyWidget({Key? key}) : super(key: key);
}

// after
class MyWidget extends Widget {
  const MyWidget({super.key});
}

Only when the parameter is passed through unchanged and not otherwise read, renamed, or given a different default.

Project hygiene #

Whole-file cleanup that runs after the structural passes settle.

Organize imports: sorts directives into dart:, package:, then relative groups, separates them with a blank line, and prunes the unused.

// before
import 'models.dart';
import 'dart:math';
import 'dart:convert'; // unused

// after
import 'dart:math';

import 'models.dart';

Sort members (off by default, use --sort-members): reorders class members into fields, constructors, getters and setters, then methods, sorted by name within each group. Fields keep their declared order, so field initialization order never changes.

It is opt-in because it never changes behaviour but produces the biggest diffs, which bury the real modernizations under moved lines.

// before
class Account {
  void deposit(int n) {}
  Account(this.id);
  final String id;
}

// after
class Account {
  final String id;
  Account(this.id);
  void deposit(int n) {}
}

Sort constructors first: lifts every constructor ahead of the other members of a class, enum, mixin, or extension type, satisfying the sort_constructors_first lint. It runs after sort members, so the two compose: sort members settles the canonical order, then this pass moves the constructors to the front. Attached doc comments and annotations travel with their constructor.

// before
class Account {
  final String id;
  Account(this.id);
  void deposit(int n) {}
}

// after
class Account {
  Account(this.id);
  final String id;
  void deposit(int n) {}
}

Fix all: applies the same bulk fixes as dart fix in the same pass: adding @override, dropping new, and more.

// before
class Dog extends Animal {
  String speak() => 'woof';
}

// after
class Dog extends Animal {
  @override
  String speak() => 'woof';
}

It applies the lints the target project enables, so new SDK lints work without any change here. Dart 3.13 added several: use_primary_constructors, use_declaring_parameters, initialize_in_field_declaration, unnecessary_primary_constructor_body, unnecessary_type_name_in_constructor, empty_container_bodies, unnecessary_const_in_enum_constructor, and async_return_with_no_await.

use_primary_constructors does the same job as the primary constructors pass. They do not clash: the pass runs first, and by the time dart fix sees the file the promotion is already done.

Abstract final classes: adds abstract final to classes that expose only static members and are never instantiated, extended, implemented, or mixed in anywhere in the analyzed project. A lone private preventing constructor is removed because abstract final already prevents external instantiation.

// before
class AppColors {
  AppColors._();
  static const primary = Color(0xFF0175C2);
  static const secondary = Color(0xFF13B9FD);
}

// after
abstract final class AppColors {
  static const primary = Color(0xFF0175C2);
  static const secondary = Color(0xFF13B9FD);
}

Skipped when the class is extended, implemented, or instantiated anywhere in the analyzed project, or already carries any class modifier. Requires full project analysis, so it runs as the final pass after all structural rewrites have settled.


๐Ÿ“‹ Requirements #

Dart SDK 3.13.0 or newer.

The minimum SDK is fixed per release. When a future Dart version ships new syntax, a new major version of dart_modernize adds support for it. Staying on an older SDK? Pin the matching release and it keeps working.

The floor is 3.13.0 because primary constructors became stable there and the promotion pass emits that syntax. The tool refuses to run on a project whose SDK constraint allows anything older, rather than letting a run reach code that cannot compile the result.


๐Ÿ“ฆ Installation #

Globally, as a CLI:

dart pub global activate dart_modernize

Or per project, as a dev dependency:

dart pub add --dev dart_modernize

๐Ÿ› ๏ธ Usage #

dart_modernize [options] [path]

dart_modernize takes an optional path and a handful of flags. With no path it runs in the current directory; with no flags it runs every pass. Nothing is written until you drop --dry-run, so start there and review the diff.

Start here #

# Preview every change as a unified diff, writing nothing
dart_modernize --dry-run

# Apply the changes once the diff looks right
dart_modernize

Choose which passes run #

Every pass runs by default except sort-members, which is opt-in. Adjust the set three ways, which compose:

# Allow-list: run ONLY the passes you name (comma-separate or repeat --only)
dart_modernize --only cascades
dart_modernize --only cascades,inline-return

# Deny-list: run everything EXCEPT the passes you switch off
dart_modernize --no-primary-constructors
dart_modernize --no-primary-constructors --no-organize-imports

# Switch on an off-by-default pass (currently just sort-members)
dart_modernize --sort-members

Each pass has one switch: --no-<name> turns an on-by-default pass off, --<name> turns an off-by-default pass on.

--only picks the starting set, and the switches add to or remove from it. The order you type them never matters; passes always run in their fixed pipeline order (see doc/ORDERING.md). dart_modernize --help lists every name.

Choose where it runs #

# Modernize one directory instead of the whole project
dart_modernize lib/

# A selection and a path together: run only cascades, over lib/
dart_modernize --only cascades lib/

The positional argument is always a path, so a directory named after a pass is never mistaken for a selection: dart_modernize cascades modernizes the cascades/ folder, whereas dart_modernize --only cascades runs the cascades pass.

Gate CI #

# Exit non-zero if any file would change, and write nothing
dart_modernize --check

# Same gate, but also print the diff of what would change
dart_modernize --check --dry-run

Options #

Option Description
-h, --help Show usage and exit.
-v, --version Print the version and exit.
-n, --dry-run Preview changes as a unified diff; write nothing.
--check Write nothing and exit non-zero if any file would change, for gating CI (like dart format --set-exit-if-changed). Prints only a summary on its own; combine with --dry-run to also print the diff.
--only <name> Run only the named passes and skip the rest. Comma-separate or repeat the flag to name several (--only cascades,inline-return). Names are the passes listed above; without --only, every pass runs.
--no-<name> Turn an on-by-default pass off, e.g. --no-primary-constructors. Composes with --only (removes the pass from the selected set).
--sort-members Switch on sort-members, the one opt-in pass (off by default because it only reorders members but can produce a large diff). Composes with --only (adds it to the selected set).
--verbose Print per-file progress and passes that made no change.
--[no-]color Force ANSI color on or off. Default: auto-detect, so color is on when writing to a terminal and off when piped or when NO_COLOR is set. --color forces it on (handy when piping to a pager); --no-color forces it off.
--exclude <glob> Extra glob pattern to skip, relative to the project root. Repeatable.
--[no-]verify Re-analyze changed files after editing and revert any that gain a new error, then exit non-zero. On by default; --no-verify skips the extra analysis.
--allow-dirty Run even when the Git working tree has uncommitted changes. By default the tool refuses on a dirty tree, so its edits land in their own reviewable diff. Skipped when the target is not in a Git repository or under --dry-run/--check.
--line-endings <auto|lf|crlf> Line endings for files the tool rewrites. auto (default) keeps each file's existing endings; lf or crlf forces one. A UTF-8 BOM is always preserved.

Run dart_modernize --help for the same reference, always current.


๐Ÿ—‚๏ธ Configuration file #

Record per-project settings in a dart_modernize: section of analysis_options.yaml, so they live in the repo instead of being repeated on every run:

dart_modernize:
  enabled:
    - sort-members        # switch on an off-by-default pass
  disabled:
    - organize-imports    # switch off an on-by-default pass
  exclude:
    - lib/generated/**    # extra globs, added to analyzer: exclude and --exclude
  • enabled switches passes on (use it for opt-in passes like sort-members).
  • disabled switches passes off.
  • exclude adds glob patterns on top of analyzer: exclude: and any --exclude flags.

CLI flags win over the file. --only replaces the file's selection entirely, and a pass's own switch overrides the file for that pass.

Each pass has only one switch, so it can only counter the file in one direction. Use --only for the other direction, for instance to run a pass the file disabled. An unknown name, or a name in both enabled and disabled, is an error.


๐Ÿšซ Excluding files #

The tool skips files in five ways, checked in order.

Built-in: always excluded, no configuration required.

Pattern Reason
*.g.dart, *.freezed.dart, *.gen.dart Code-generation outputs
*.gr.dart, *.pb.dart, *.pbenum.dart Router and protobuf outputs
build/** Build directory
leading // GENERATED CODE - DO NOT MODIFY, // DO NOT EDIT, // AUTO-GENERATED Generated code that uses a plain file name (e.g. some build_runner outputs). The marker is only honored in the file's leading comment block.

l10n.yaml: honored automatically. When a project declares one, the flutter gen-l10n output it points at (output-dir and output-localization-file, defaulting to lib/l10n/app_localizations.dart) and every per-locale sibling (app_localizations_fr.dart, โ€ฆ) are skipped, since they are regenerated on the next build. Without an l10n.yaml, a hand-written app_localizations.dart is treated like any other source.

analysis_options.yaml: honored automatically. Any pattern listed under analyzer: exclude: is picked up without any extra flags:

analyzer:
  exclude:
    - lib/src/proto/**
    - test/golden/**

dart_modernize: exclude:: the project config section above takes exclude globs too, merged with the analyzer: exclude: list and any --exclude flags. Use it for paths you want skipped by this tool but kept in the analyzer's own view.

--exclude flag: for ad-hoc patterns not already in analysis_options.yaml. The pattern is matched against the path relative to the project root and the flag can be repeated:

# Exclude a single directory
dart_modernize --exclude "lib/legacy/**"

# Exclude multiple paths
dart_modernize --exclude "lib/legacy/**" --exclude "test/snapshots/**"

# Combine with a target path
dart_modernize lib/ --exclude "lib/src/vendor/**"

๐Ÿงญ How it works #

  validate  โ”€โ”€โ–ถ  resolve  โ”€โ”€โ–ถ  transform  โ”€โ”€โ–ถ  finalize
   pubspec       full type      type-safe        fix ยท organize
   + SDK         resolution     edits, in        sort ยท format
   check                        ordered passes
  1. Validate. Checks that a pubspec.yaml exists and declares an SDK constraint, so the project can be resolved.
  2. Resolve. Loads the project with full type resolution, library by library.
  3. Transform. Runs a fixed sequence of pass groups. Each group is resolved once and applied before the next runs, so a pass that builds on an earlier one (a shorthand over a switch expression another pass produced, say) reads the finished result. See doc/ORDERING.md.
  4. Finalize. Applies dart fix, organizes imports, sorts members, and runs dart format.

Re-running is safe: the first run does all the work and later runs change nothing. The tool is idempotent by design.


๐Ÿ›ก๏ธ Safety #

  • Dry run first. Produces a full diff before touching any file.
  • Skips generated code. Ignores *.g.dart, *.freezed.dart, and other build outputs, flutter gen-l10n localization files, and any file carrying a DO NOT EDIT header.
  • Refuses ambiguity. Will not apply a shorthand when the context type is too imprecise to guarantee an identical result.
  • Preserves evaluation. Keeps the number of times an expression runs identical, so it skips sugar like ?expr unless the operand is provably stable and side-effect free.
  • Type-checked edits. Every rewrite is computed from fully resolved types, so the targeted element and the static type stay identical.
  • Verifies and rolls back. After editing, re-analyzes the changed files and restores any that gained a new error, so a run never leaves a file that no longer compiles (--no-verify opts out).
  • Refuses a dirty tree. Stops before writing if the Git working tree has uncommitted changes, so the modernization stays in its own reviewable diff (--allow-dirty opts out; skipped outside a repo and under --dry-run/--check).
  • Preserves line endings and BOM. Each file's original CRLF/LF endings and any UTF-8 BOM are restored after formatting, so an edit shows only the lines that changed instead of a whole-file whitespace diff (--line-endings overrides).

Run on a clean working tree, review the diff, then commit.


๐Ÿค Contributing #

Contributions are welcome. Read CONTRIBUTING.md, then make sure your change passes dart format, dart analyze, and dart test before opening a pull request.


Released under the MIT License.

Built with the official Dart analyzer. Type aware, behavior preserving, idempotent.

1
likes
160
points
371
downloads

Documentation

API reference

Publisher

verified publisheribrahimasylla.com

Weekly Downloads

A CLI tool that modernizes Dart and Flutter codebases by applying idiomatic patterns, enforcing conventions, and automating tedious upgrades.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

analyzer, args, glob, path, pub_semver, tint, yaml

More

Packages that depend on dart_modernize