Skip to main content

throws_plugin 2.1.0 copy "throws_plugin: ^2.1.0" to clipboard
throws_plugin: ^2.1.0 copied to clipboard

Analyzer plugin for @Throws/@throws usage.

throws_plugin #

Analyzer plugin for @Throws/@throws usage.

Install #

Add the plugin dependency to your pubspec.yaml and enable it in analysis_options.yaml.

analysis_options.yaml:

plugins:
  throws_plugin:
    version: any

What the plugin enforces #

The plugin analyzes resolved AST (except files under test/, integration_test/ and test_driver/ of a package, where the test framework catches errors) and:

  • Requires @Throws/@throws on functions that throw.
  • Requires callers to handle throwing calls with try/catch or declare errors.
  • Validates that declared errors match actual thrown errors.
  • Accounts for overrides by walking the override chain.

Lints #

  • missing_throws_annotation: functions that throw must be annotated with @Throws/@throws
  • no_try_catch: calling a throwing function without a try/catch or annotation
  • no_try_catch_in_closure: calling a throwing function in a closure whose errors nothing outside can catch (not passed to a @rethrows function)
  • not_exhaustive_try_catch: try/catch does not cover all expected errors
  • throws_annotation_mismatch: @Throws errors do not match actual thrown errors
  • introduced_throws_in_override: overrides introduce new errors without annotation
  • unused_throws_annotation: @Throws on functions that do not throw

Diagnostics are reported at the call site for unhandled throws.

Assists and fixes #

Assists:

  • Add @Throws() or @throws to a function.
  • Specify errors: turn @throws / @Throws() into @Throws(errors: {...}) with the errors the function throws.

Fixes:

  • missing_throws_annotation: add @Throws with the inferred errors.
  • throws_annotation_mismatch: update @Throws errors.
  • unused_throws_annotation: remove the annotation.
  • no_try_catch:
    • wrap the statement in try/catch, with a clause per error or one on Object clause (final x = call(); becomes late final T x; try { x = call(); }, void f() => call(); becomes a block body);
    • add await to a future-returning call inside try/catch;
    • add the errors to the enclosing @Throws, or annotate the enclosing function.
  • no_try_catch_in_closure: wrap the statement (or a void => body) in try/catch inside the closure, or add await.
  • not_exhaustive_try_catch: add the missing catch clauses (subtypes before supertypes), a default on Object clause, or add the errors to @Throws.
  • introduced_throws_in_override: add the errors to @Throws of the overridden member (when it is in your package), or wrap the call in try/catch.

Generated code selects the reason and the catch bodies as linked edits.

The annotation fixes for missing_throws_annotation, throws_annotation_mismatch and unused_throws_annotation can be applied to every diagnostic in a file at once ("fix all in file" in the IDE). dart fix does not apply analyzer plugin fixes.

throws.yaml #

Place throws.yaml at the package root to declare errors of members outside your package (SDK or dependencies).

throws:
  # Errors to check: exception (subtypes of Exception), error (subtypes of
  # Error), or both (default). Types that are neither are always checked.
  track: [exception, error]

  # Bundled maps: dart, flutter. Off by default.
  prebuilt: [dart]

  # YAML files, or directories of them, in the same format as `members`
  # (e.g. throws_collector output; its `dependencies` input writes a map
  # per package in pubspec.lock).
  include:
    - throws_collector_gen/

  # Library URI, then member: errors (a name or a list).
  members:
    dart:core:
      Iterable.single: [StateError]
      int.parse: FormatException
    package:http/http.dart:
      get: [ClientException]
      Client.new: [ArgumentError]

  # Library URI, then members that throw what their callbacks throw, like
  # `@rethrows`.
  rethrows:
    package:retry/retry.dart: [retry, RetryOptions.retry]

Member names follow Dart tear-off syntax: function for top-level functions, Class.member for methods, getters and setters, Class.new and Class.named for constructors.

Notes:

  • Prebuilt maps are heuristic only and come with no guarantees of completeness or accuracy.
  • Entries from members and include take precedence over prebuilt maps.
  • prebuilt: [dart] also marks SDK higher-order members as rethrowing: Iterable.forEach/map/where/..., List.sort, Map.putIfAbsent, Future.then/catchError/whenComplete, Future.new/sync/microtask, runZoned and others.
  • Unknown keys are ignored. Version 1 keys (include_prebuilt, include_paths, custom_errors, include_sdk_errors) are no longer read.

How errors are resolved #

When evaluating a call, the plugin resolves expected errors in this order:

  1. @Throws/@throws on the target element.
  2. throws.yaml (members and include).
  3. Prebuilt maps (dart/flutter) if enabled.
  4. Overrides (walks the override chain until a throwing element is found).

If none are found, the call is treated as non-throwing.

Errors are compared as types, including subtypes: on Exception handles a call that throws FormatException, and @Throws(errors: {Exception}) covers a thrown FormatException. Names from throws.yaml and prebuilt maps are resolved in the scope of the library that declares the member; unresolved names count as an unknown error, which any catch clause handles.

Closures and @rethrows #

A closure is checked as a function of its own. What happens to its errors depends on where it goes:

  • passed to a parameter of a function annotated with @rethrows (or listed under rethrows), its errors are thrown by that call, so the caller handles or declares them:

    @rethrows
    T measure<T>(T Function() action) => action();
    
    @Throws(errors: {FormatException})
    int parse(String s) => measure(() => int.parse(s));
    
  • anywhere else (onPressed: () => save(), Timer(d, () => ...), a variable), nothing outside can catch them: no_try_catch_in_closure reports throwing calls inside it. An explicit throw in a closure is not reported.

A tear-off of a known function (list.forEach(parse)) is treated like a closure that calls it. An async closure passed to a parameter that returns void (forEach((x) async => ...)) loses its errors in a dropped future, so its errors stay inside it.

@rethrows is inherited by overrides and combines with @Throws for the function's own errors. Lazy members (map, where, ...) throw when iterated, but their callback's errors are reported at the call.

Futures and streams #

Errors of a call that returns a Future arrive asynchronously:

  • a surrounding try/catch handles them only if the call is awaited (await f(), await Future.wait([f()]));
  • .catchError, .ignore() and .then(..., onError: ...) handle all of them, .onError<E> handles subtypes of E.

Errors of a call that returns a Stream arrive as error events:

  • a surrounding try/catch handles them only through await for (x in s());
  • .handleError and .listen(..., onError: ...) handle all of them;
  • .listen without onError leaves them unhandled;
  • getters and methods that return a future (await s().first) follow the future rules; yield* s() forwards them to the caller.

A future or stream stored in a local variable (final f = load();) is handled where the variable is read: await f inside try/catch, f.catchError(...), await for (x in f). Diagnostics point at that read. If no read awaits or handles it, or the variable is reassigned or read in a closure, the call itself is reported. Fields and other non-local storage are not tracked.

Performance #

Measured on Flutter DevTools (packages/devtools_app: 487 files, 115k lines in lib), Apple M4 Max, Flutter 3.49 master, prebuilt: [dart, flutter]. dart analyze, mean of 3 runs:

Setup Cold cache Warm cache
No plugin 22.3 s 4.3 s
Empty analyzer plugin (no rules) 45.0 s 24.6 s
throws_plugin 46.4 s 25.2 s

Almost all of the extra time comes from loading any analyzer plugin: the plugin isolate analyzes the project again. throws_plugin itself takes 120 ms for all 487 files (warm JIT; p95 0.8 ms, max 2.4 ms per file) next to 13.6 s of resolution. Measure your project with dart run tool/benchmark.dart <dir>.

In the IDE (the same analysis server over LSP, warm cache, one run each):

Setup First diagnostics Memory (settled / peak) Edit to diagnostics
No plugin 4.4 s 1.3 / 1.3 GB 307 ms
Empty analyzer plugin 36.5 s 4.2 / 4.8 GB 328 ms
throws_plugin 25.0 s 4.2 / 4.7 GB 319 ms

Any analyzer plugin costs about 3 GB of memory and 20–30 s before the first diagnostics on a project of this size. Diagnostics after an edit arrive 10–20 ms later than without a plugin.

Limitations #

  • Casts (as) and null checks (!) are not treated as throwing: they throw TypeError, a programming error.

  • A catch that may rethrow (even conditionally) does not count as handling.

  • Function types carry no errors: a callback stored in a variable or field, or passed through an unannotated parameter, loses them. A @rethrows function rethrows all errors of its arguments, even those it catches itself.

  • Analysis is best-effort and depends on available type information.

  • SDK/dependency coverage depends on map data (prebuilt or collected).