A comprehensive Dart analyzer plugin that provides powerful annotations and static analysis rules for them.
@Throws: Declare the exceptions that a function can throw, enabling better documentation and static analysis of error handling.@IgnoreThrows/@ignoreThrows: Suppress this plugin's throws diagnostics for the annotated declaration —handle_throwing_invocations(and its test-directory companion) for invocations inside it, anddeclare_thrown_exceptions/require_throws_declarationfor directthrows inside it. See Quick Fixes & Assists below.
handle_throwing_invocations: Ensures that any function that calls a function annotated with@Throwseither catches the declared exceptions or also declares them with@Throws.handle_throwing_invocations_in_tests: The same rule, reported under its own diagnostic code for code undertest/,integration_test/,test_driver/,testing/,tool/, andbenchmark/directories, so it can be toggled independently of the main rule. See Configuration.declare_thrown_exceptions(opt-in): A function annotated with@Throwsmust cover every exception type it directlythrows (subtypes of a declared type count; the degenerate@Throws({})is treated as a blanket declaration and never flagged). Flagged at the throw expression.require_throws_declaration(opt-in, strict): Any function that directlythrows a non-excluded exception type must declare it with@Throws. Never double-reports withdeclare_thrown_exceptions. Enable the two as a pair: this rule checks only that an annotation exists — once any@Throwsis present, checking its completeness isdeclare_thrown_exceptions' job, so with this rule alone a partial annotation silences the remaining throws. See Undeclared-throws configuration.
When handle_throwing_invocations reports an unhandled invocation, your IDE
(IntelliJ/Android Studio, VS Code — anything speaking to the Dart Analysis
Server) offers these quick fixes:
- Wrap in 'try' with an 'on' clause per declared exception
- Wrap in generic 'try-catch'
- Add missing 'on' clauses to the enclosing 'try' — when the call is already inside a try that doesn't cover the declared types
- Add '@Throws' to the enclosing function — propagate instead of handle;
merges into an existing
@Throwsset - Suppress with '@ignoreThrows' — inserts a bare
@ignoreThrowsannotation on the enclosing function/method/constructor/top-level variable/field declaration (adding thehyper_lintsimport if needed), silencing the diagnostic by suppression instead of handling or propagating it
For the undeclared-throws rules (declare_thrown_exceptions /
require_throws_declaration), the IDE offers:
- Declare the thrown type in '@Throws' — creates a
@Throwsannotation on the enclosing function/method/getter/setter/constructor, or merges the thrown type into an existing set - Suppress with '@ignoreThrows' — the same suppress fix listed above for the call-site rule, registered for these diagnostics too
Annotate a function, method, getter, setter, field, top-level variable, or
constructor to suppress handle_throwing_invocations for invocations inside
it:
@Throws({FormatException})
void parseData(String input) { /* may throw FormatException */ }
@ignoreThrows // bare form: suppresses every declared exception type
void callerThatAcceptsAnyRisk() {
parseData('...'); // not flagged
}
@IgnoreThrows({FormatException}) // typed form: only these types
void callerThatAcceptsFormatExceptionOnly() {
parseData('...'); // not flagged: FormatException is covered
}The typed set only suppresses invocations whose entire declared @Throws
set is covered by it — a call declaring a type outside the set still lints.
ignoreThrows is shorthand for the bare IgnoreThrows() constructor.
- The rule also flags bare (unqualified) getter reads — e.g. a top-level
or local
@Throwsgetter read as plainriskyValue, not justobj.riskyValue— compound-assignment reads (riskyValue += 1), and setter writes: an assignment to a@Throwssetter (riskyValue = 1orobj.riskyValue = 1) invokes the setter and is checked like any other invocation. When a compound assignment hits both an annotated getter and an annotated setter, one diagnostic is reported, not two. - Operator invocations are enforced too:
a + b,a[0],a[0] = v,-a,x++, andx += bcheck the resolved operator method's@Throws; compound forms report one diagnostic carrying the union of the getter/setter/operator contracts. .ignore()andunawaited(...)(matched by name, so re-exports work too) on a flaggedFuture-returning call are treated as handled, including through a.then()/.whenComplete()/.timeout()chain, e.g.unawaited(risky().then((_) {}));. Noteunawaitedmarks a future as deliberately fire-and-forget — it discards errors rather than handling them; treating it as "handled" is a design decision matching the SDK idiom's intent.
Two assists are available on any try statement (no diagnostic needed):
- Add 'on' clause — inserts a template
on Exception catch (e)clause - Narrow 'catch' to declared exception types — when a broad
catchswallows specific@Throwstypes thrown inside the try body, inserts specificonclauses above it
Fixes are async- and scope-aware:
awaitinsertion — when the flagged call returns aFutureand the enclosing function body isasync, the try-catch fixes insertawaitso the handler actually catches. In a sync body, an un-awaited async call throws after the try/catch has already returned, so no wrap fix orAdd missing 'on' clausescan ever silence the diagnostic for it — those fixes aren't offered for un-awaited async calls in sync bodies (await it, or see the SDK'sunawaited_futureslint).Add '@Throws'is also NOT offered for a fire-and-forget call: an un-awaited, un-returned Future fails out-of-band, so its error never reaches the caller's future and a@Throwson the caller cannot cover it (the rule likewise refuses such an annotation as propagation).- Declaration splitting — wrapping
final x = risky();whenxis used later splits the declaration out of the try as a nullable variable (int? x;), keeping later code in scope. Later uses may need!at typed use sites; the fix does not rewrite them. - Apply in file — the two wrap fixes (
Wrap in 'try' with 'on' clausesandWrap in generic 'try-catch') offer an "everywhere in file" variant in the IDE. (dart fixon the command line cannot apply plugin fixes yet; see dart-lang/sdk#53402.) - Narrowing is nesting-aware — the narrow-catch assist ignores exception types already handled by nested try statements.
- Type matching uses real subtype checks everywhere. Only a bare
catch,on Object, andon dynamicare universal —on Exceptionandon Errorare NOT catch-alls for any rule, since (for example)on Errorcan never catch a type that implementsException.
After upgrading the plugin, restart the Dart Analysis Server (IntelliJ: Dart Analysis tool window → restart icon) to pick up the fixes.
Requires Dart 3.11+ (Flutter with Dart 3.11+).
Add this package as a dependency:
dependencies:
hyper_lints: ^1.3.0You can configure it in your analysis_options.yaml. Every rule is
opt-in: all four rules (handle_throwing_invocations,
handle_throwing_invocations_in_tests, declare_thrown_exceptions, and
require_throws_declaration) are OFF by default and only take effect once
explicitly listed as true in the diagnostics: map below — listing one
does not enable any other.
plugins:
hyper_lints:
version: ^1.3.0
diagnostics:
handle_throwing_invocations: true
# Set to `false` (or omit this line entirely) to silence `test/`,
# `tool/`, `benchmark/`, and `integration_test/` code instead of
# flagging it.
handle_throwing_invocations_in_tests: truehandle_throwing_invocations_in_tests reports the same problem under its
own diagnostic code for code under test/, integration_test/,
test_driver/, testing/, tool/, and benchmark/ — it has its own on/off
switch, so you can enable the rule in your main code while disabling it for
tests (or vice versa):
diagnostics:
handle_throwing_invocations: true
handle_throwing_invocations_in_tests: falseMigrating from an earlier version: if your existing config lists only
handle_throwing_invocations: true, it will keep flagging lib/ code but
will no longer flag test/, tool/, benchmark/, or
integration_test/ code after upgrading, since
handle_throwing_invocations_in_tests is a separate, independently opt-in
rule rather than something the main rule's true also implies. Add
handle_throwing_invocations_in_tests: true to your config to keep flagging
that code too.
Two 1.3.0 changes can alter existing diagnostics:
@Throws/@IgnoreThrowsare recognized only when declared by thehyper_lintspackage (re-exports still work). If you vendored copies of the annotation classes, all diagnostics for them stop — depend on the real annotations instead.- Multi-type
@Throws({A, B})invocations now require all declared types to be handled (catching just one no longer silences the rest), so new diagnostics may appear on call sites that were previously under-checked. Handling composes: types caught locally are subtracted, and only the remainder needs declaring or suppressing.
Enable the rules in the diagnostics: map like any other — as a pair:
require_throws_declaration only checks that a @Throws annotation exists,
and declare_thrown_exceptions only checks a present annotation's
completeness, so enabling just one leaves the other half of the contract
unenforced. Then (optionally) configure them via a top-level
hyper_lints: key — the analyzer's plugin config schema only supports
per-rule on/off, so list/flag options live in their own section:
plugins:
hyper_lints:
version: ^1.3.0
diagnostics:
declare_thrown_exceptions: true
require_throws_declaration: true
hyper_lints:
# Class names never required in @Throws (matched by simple name; a
# listed type's SUBTYPES are excluded too).
exclude_throws: [TelemetryException]
# By default anything assignable to dart:core's Error (StateError guards,
# ArgumentError, custom Error subclasses) is exempt — Effective Dart
# treats Errors as programmer bugs, not API contract. Set true to check
# them too.
include_errors: falseNotes:
- Throws inside closures and local functions don't count against the
enclosing function; a local
trythat catches the type (and doesn'trethrow— orthrow ethe caught variable, which is treated the same) silences the rules. An unannotated local function's body is not checked by any rule; a local function that itself carries@Throwsis verified bydeclare_thrown_exceptions(its call sites are enforced byhandle_throwing_invocationseither way). Closures passed directly as invocation arguments are assumed to run synchronously (forEach,map,sort, ...); this deliberately trades away strictness for known-deferred APIs (Timer(...),Future.delayed(...), event handlers), whose callbacks regain 1.2.0-era false negatives — a future refinement may special-case them. require_throws_declarationalso fires onmain()and other entrypoints — no caller consults their@Throws, so annotate, catch, or suppress with@ignoreThrowsthere as you prefer; the rule doesn't special-case entrypoints.- Awaiting a wrapper that receives a risky future (
await consume(risky())) is assumed to forward the failure to theawait; a wrapper that silently drops its argument's future defeats that assumption. This mirrors the argument-closure synchrony assumption and errs permissive by design. - Field and top-level-variable initializers, and constructor initializer
lists, are not checked by the undeclared-throws rules in this iteration
(
final x = throw ...;is exempt) — they have no function body to attribute the throw to. - Config values are strict:
include_errorsmust be a literal YAML boolean (true/false— notyes/on), and unknown or mistyped keys are silently ignored (the analyzer's own options validation doesn't see this section). A bare string is accepted for a singleexclude_throwsname. - The config walk searches ancestor directories all the way up, exactly
like the analyzer's own options lookup — so a monorepo's root
analysis_options.yamlgoverns member packages here precisely when itsplugins:section does. (The flip side, also matching the analyzer: ananalysis_options.yamlin a directory above your repo would be consulted too.) - Only types assignable to
ExceptionorErrorare checked:throw 'message'and other non-throwable objects are the SDK'sonly_throw_errorslint's domain, not a declarable contract. Generator bodies (sync*/async*) are never checked — their throws surface on iteration, where no try around the call can catch them. @Throwsis per-declaration, not inherited: an override that throws must re-declare, even when the interface member is annotated. This is deliberate — call sites resolve statically, so the contract has to be present on every static target a caller might resolve to.- Catch-and-propagate of an unannotated callee's exception is not
reported by any rule: in
try { callback(); } on X { rethrow; }(orthrow e), the rules know nothing about what an unannotatedcallbackthrows — the clause author is asserting knowledge the analysis doesn't have. When the try body's exception origin is reportable (a directthrow, or a@Throwscallee), the origin reports and the propagation correctly doesn't double-report. @ignoreThrows/@IgnoreThrows({...})on the declaration suppresses these rules the same way it doeshandle_throwing_invocations.- The section is looked up in the nearest
analysis_options.yaml, including anything itinclude:s by relative path (nearest section wins: the including file beats its includes, a later include beats an earlier one). The winning file's section is taken wholesale — there is no per-key merging across the chain, so a project-local section fully replaces one from a shared base file.package:includes are not resolved — if your shared config lives in a package-included file, copy thehyper_lints:section into the project's own options file. Edits to any file in the chain take effect on the next analysis (same as toggling a rule indiagnostics:); only creating a brand-newanalysis_options.yamlnearer to your sources needs an analysis-server restart. This deviates from the analyzer's own per-key deep-merge of options sections and is intentional v1 behavior — if you splithyper_lints:keys across an include chain, the losing file's keys are silently dropped, so keep the whole section in one file. - Both rules skip
test/,integration_test/,test_driver/,testing/,tool/, andbenchmark/code, and — unlikehandle_throwing_invocations— have no_in_testscompanion yet, so that code is never checked by them. exclude_throws/include_errorsaffect only the two undeclared-throws rules;handle_throwing_invocationsnever consults them (its contract comes from the@Throwsannotations themselves).- A
finallyblock that unconditionally throws replaces the in-flight exception at runtime; the rules don't model that (such code is broken by construction — the original exception is silently lost), so the original throw is still reported.
@Throws({CustomException})
void riskyFunction() { /* ... */ }
// ✅ Specific exception type
try {
riskyFunction();
} on CustomException catch (e) {
// handle
}
// ✅ General Exception catch
try {
riskyFunction();
} on Exception catch (e) {
// handle
}
// ✅ Catch-all
try {
riskyFunction();
} catch (e) {
// handle
}
// ✅ Rethrowing with @Throws
@Throws({CustomException})
void callerFunction() {
riskyFunction(); // OK because caller also declares @Throws
}
// ❌ Not declaring @Throws in caller
void anotherCallerFunction() {
riskyFunction(); // Warning: callerFunction should declare @Throws
}
// ❌ Wrong exception type caught
try {
riskyFunction();
} on StateError catch (e) {
// This doesn't catch CustomException!
// Warning: Unhandled exception from invocation annotated with @Throws
}
// ❌ Rethrowing catch clause
try {
riskyFunction();
} on CustomException {
rethrow; // The exception still escapes (even after logging first)!
// Warning: Unhandled exception from invocation annotated with @Throws
// Catch it in an outer try, or declare @Throws on the enclosing function.
}
// ❌ Undeclared direct throw (declare_thrown_exceptions)
@Throws({CustomException})
void submit(bool bad) {
if (bad) throw FormatException('bad'); // Warning: FormatException is
// not declared in @Throws
throw StateError('disposed'); // OK: Errors are exempt by default
}@Throws({CustomException})
Future<void> riskyAsyncFunction() async { /* ... */ }
// ✅ Awaited call inside try-catch
try {
await riskyAsyncFunction();
} catch (e) {
// handle
}
// ✅ Using .catchError()
riskyAsyncFunction().catchError((e) {
// handle
});
// ✅ Using .then() with onError
riskyAsyncFunction().then((_) {
// success
}, onError: (e) {
// handle
});
// ✅ Chained .then().catchError()
riskyAsyncFunction()
.then((_) => print('success'))
.catchError((e) => print('error'));
// ❌ Non-awaited call - try-catch won't catch async exceptions!
try {
riskyAsyncFunction(); // Warning: async call not awaited
} catch (e) {
// This won't catch the exception!
}
// ❌ Unhandled async call
riskyAsyncFunction(); // Warning: Unhandled exceptionContributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
This project is licensed under the MIT License - see the LICENSE file for details.