Skip to content

Introduce throws keyword (like Swift) for marking throwable functions #4321

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
tilucasoli opened this issue Apr 9, 2025 · 16 comments
Open
Labels
feature Proposed language feature that solves one or more problems

Comments

@tilucasoli
Copy link

tilucasoli commented Apr 9, 2025

Dart currently lacks a way to explicitly annotate when a function or method is capable of throwing an error. This makes it difficult to reason about error handling in larger projects or when handling error on third-party packages.

I propose introducing a throws keyword (similar to Swift) that allows developers to declare when a function may throw, e.g.:

String parseIntStrict(String input) throws {
  if (!RegExp(r'^\d+$').hasMatch(input)) {
    throw FormatException("Invalid number format");
  }
  return int.parse(input);
}

This could help:

  • Improve code clarity around exception handling
  • Allow static analysis tools to give better hints (e.g., “this function might throw but isn’t in a try-catch block”)
  • Make APIs safer and more self-documenting (specially community packages)
  • Enable stricter opt-in safety modes (e.g., in Flutter, package:analyzer, etc.)

Brainstorm

As annotation

@throws
String parseIntStrict(String input) {
  if (!RegExp(r'^\d+$').hasMatch(input)) {
    throw FormatException("Invalid number format");
  }
  return int.parse(input);
}

like async keyword

String parseIntStrict(String input) throws {
  if (!RegExp(r'^\d+$').hasMatch(input)) {
    throw FormatException("Invalid number format");
  }
  return int.parse(input);
}
@tilucasoli tilucasoli added the feature Proposed language feature that solves one or more problems label Apr 9, 2025
@mmcdon20
Copy link

mmcdon20 commented Apr 9, 2025

One of the biggest problems with writing try catch in dart is that is figuring out which specific exceptions can be thrown, (not just if an exception can be thrown). So I think you would need to be able to specify the exception types.

int parseIntStrict(String input) throws FormatException { 
  if (!RegExp(r'^\d+$').hasMatch(input)) {
    throw FormatException("Invalid number format");
  }
  return int.parse(input);
}

That way the tooling can suggest:

void main() {
  try {
    final x = parseIntStrict('hello');
  } on FormatException catch (e) { // catching specific exception
    print('unable to parse x');
  }
}

Instead of just:

void main() {
  try {
    final x = parseIntStrict('hello');
  } catch (e) { // catching any exception
    print('unable to parse x');
  }
}

@lrhn
Copy link
Member

lrhn commented Apr 9, 2025

The current recommended way to document exceptions is to have a paragraph in the DartDoc starting with the word "Throws" and linking to the exceptions being thrown.

If your function had that,

/// ...
/// Throws a [FormatException] if not
/// a sequence of digits.

then your users can see what to throw, and tools could choose to pick up on that too.
It would be possible to have a lint like handle_exceptions which requires you to catch or document exceptions for all documented functions that your function calls.
Maybe even document_exceptions which asks you to document the exceptions that your function throws.

The documented exceptions of a function are those classes which subtype Exception which are linked to from a paragraph starting with the word Throws. There can be more than one such paragraph, and they can be linking to other classes too, only the Exceptions count.

@pongloongyeat
Copy link

@mmcdon20, I believe Swift addresses a similar concern by generalising all functions as non-throwing unless specified

A translated Dart-equivalent would roughly look like:

int nonThrowable() => 2;

// Equivalent to
int nonThrowable() throws Never => 2;

int throwable() throws => throw 2;

// Equivalent to
int throwable() throws Object => throw 2;

// Self-explanatory
int specificThrowable() throws FormatException => throw FormatException('2');

Though, I'm not sure how much of a breaking change this would be, since all functions/methods would be assumed as non-throwing in the current case.

@tilucasoli
Copy link
Author

@lrhn I believe your suggestion of create those two lint rules handle_exceptions and document_exceptions are a good start to ensure that developers are documenting their code better

@lrhn
Copy link
Member

lrhn commented Apr 9, 2025

The problem with throws (and even more with throws Something) is that it's breaking to add it, and it's contagious.

If a function you call adds throws, your function (with no error cathcing and not throws) will stop compiling.
With typed throws, which should be able to name more than one type, adding , E3 to throws E1, E2 is just as breaking as adding the first throws.

If someone added throws to, say, jsonEncode (which can throw for any number of reasons), then any code that calls jsonEncode must either ... handle it? (How? Likely by throwing again, because what else can they do, they don't have a JSON value to work with!) ... or they must mark themselves as throws too. So they do. And so the throws propagates up the call hierarchy until it reaches main. It's another color for your function, different from async, but similarly contagious.

Any function call can throw, if only due to stack overflow. Dart reifies that as an in-program error object.
Only those marked as throws need to be handled. Probably it means throws is for Exceptions and non-throws
only throws Errors. (Is it Kotlin which makes an error throw just call exit, no handling at all?)

That also leads us to async. If a function is Future<int> foo() async throws { ... } then we know that ... what? The Future can have an error. We already knew that, all futures can. If you call it as await foo(), we can probably recognize that that expression can throw. If you do: var future = foo(); something(); await future;, can we know that that might throw?
Do we need to track "comes from a throws function" for any future-typed value?
What will Future<int> bar() => foo(); do? It's a synchronous function (no async) that returns a Future. It doesn't throw synchronously, so it can't be marked throws. But it returns a future which can throw.
Do we need a Future and a ThrowingFuture, and an async throws function must return a ThrowingFuture which is a supertype/subtype/sibling type of Future? Probably supertype, otherwise it can be upcast to Future and lose information, so it'll be Future and NoErrorFuture instead. Which makes having an error the default for async.
Should it be for sync too?

It's interesting to read the C# approach to this: https://www.artima.com/articles/the-trouble-with-checked-exceptions
They're very pragmatic, and what people are actually doing is not catching and handling exceptions, it's just having enough finallys to clean up their own code, and let their caller deal with the exception. Until the event loop or your UI framework catches and reports it.

So maybe go the other way: Add a nothrow marker that you can put on a function. Such a function can only call other nothrow functions. If async, it can only await a NoThrowFuture, which an async nothrow function returns.
There can also be an async* nothrow creating a NoThrowStream and sync* nothrow creating a NoThrowsIterable,
which guarantees that the stream contains no errors, and proper use of the iterable's iterator (call moveNext() until it returns false, read current only after returning true) throws no error.

We can promise that fx int.+ is nothrow, most simple operations are (not ~/ 0). That gives you a base.
If all your function does is simple control flow on numbers and simple string operations, then you're safe.
But we can't abstract over purity, so StreamBuffer.add can't be nothrow because the toString of the object might throw.
Unless we make Object.toString be nothrow and enforces that any override must inherit and preserve the nothrow of a superinterface. (Would actually be nice to know that toString cannot throw, but there are definitely ones today that can, for a number of reasons like cycle-detection or just invalid states. Those would have to return something like "" or "". Or it's Errors, and then it's OK.)

@ykmnkmi
Copy link

ykmnkmi commented Apr 9, 2025

Another way is to write and use annotations and analyzer plugin:

@Throws<FormatException>()
@Throws<int>()
void canThrowExcpetionsAndObjects() {
  // pass
}

@tilucasoli
Copy link
Author

tilucasoli commented Apr 9, 2025

@lrhn what if we create something like @Deprecate? The comment could be set automatically.

@Throws(AnyException)
int parseIntStrict(String input) { 
  if (!RegExp(r'^\d+$').hasMatch(input)) {
    throw FormatException("Invalid number format");
  }
  return int.parse(input);
}

@mmcdon20
Copy link

mmcdon20 commented Apr 9, 2025

@lrhn I think what most users want is not actually checked exceptions just a better way to signal to the tooling what exceptions are thrown, and for the tooling to actually recognize these signals.

Take for example int.parse:

Image

Note that the int.parse function does have dartdocs pertaining to to FormatException but it is not displayed prominently alongside the type information, you have to scroll down and dig into the docs to find it.

Additionally if you use the surround with try-catch it does not know what specific exceptions to catch.

Image

Image

See that on Exception catch (e) is generated instead of on FormatException catch (e).

@mateusfccp
Copy link
Contributor

mateusfccp commented Apr 9, 2025

@lrhn I think what most users want is not actually checked exceptions just a better way to signal to the tooling what exceptions are thrown, and for the tooling to actually recognize these signals.

...

That would be the best approach IMO. Full fledged checked exceptions are hated by many (and loved by many for some reason), and would be a big change with many problems, as explained by a lot of people here and in the other issue about checked exceptions.

However, having support through linters and tooling would be a seamless and more approachable (sic) approach.

@RohitSaily
Copy link

RohitSaily commented Apr 12, 2025

I agree that forcing exception checks is not ideal because as @mateusfccp stated, I would assume theres a significant enough number of users that do not always want that. I like thorough static checks but if I'm writing a quick short-lived script just to achieve a task then exceptional cases aren't normally a concern and having to constantly deal with them while just trying to quickly accomplish a task would be a frustrating experience.

On the other hand if I'm developing a long-lived Flutter app then it is advisable to handle exceptional cases thoroughly and as close to their corresponding context as possible to ensure a greater user experience. For example, if I forgot to handle exceptions of an IO operation, I don't want that to crash the entire app. If an error occurred I would prefer to display a specific message to the user and perhaps perform some sensible default operation based on the specifics. In this case I want to be warned about places where I missed handling of exceptions.

Being told by a static analyzer where exceptions can occur and are not being handled makes the job easier. I agree that a lint rule should be introduced that a project can enable to specifically detect a thrown Exception (not Error or other kinds) so that it is opt-in. It can require a developer to achieve case coverage like done with switch statements, where on Exception (or a less advisable catch all clause such as on Object or just catch) is like a default case. Exceptions can also be covered in groups via common supertypes.

In terms of technical feasibility, if automated control flow analysis would be too slow then we can go with the annotation approach of the defined function having to declare it throws. Although it would be convenient if the rule could just be opt-in and just have the code-base checked without requiring creators of API to broadcast whether or not their APIs throw or what their APIs throw.

@zhxst
Copy link

zhxst commented Apr 17, 2025

I guess add throws is no different than original error object in parameters. They are all contagious.
It's like a trade off, It's either we accept contagious, or we dig into documents(and source code) to find out all the exceptions.
If some could find out a new way to solve both problems (automatically), that would be real magic 😂.

@RohitSaily
Copy link

@zhxst:

I guess add throws is no different than original error object in parameters. They are all contagious.
[...]
If some could find out a new way to solve both problems (automatically), that would be real magic 😂.

I agree it's almost extremely contagious like async, but unlike that, at some point exceptions can just be caught and handled which stops further propagation of throws.

The thing is the contagiousness is desirable for complex projects where exhaustive control over error response is wanted. But since it is not always desirable eg in a short tool script, it should be opt-in.

@Protonull
Copy link

Full fledged checked exceptions are hated by many (and loved by many for some reason)

I'd wager that the hate for checked exceptions is largely, if not mostly, a misdirection: that it's more about the lack of ergonomic error handling. When handling exceptions turns your code from this:

final int example = int.parse("123");

into this:

final int example;
try {
  example = int.parse("123");
} on FormatException {
  return;
}

It's no wonder that many loathe the idea of checked exceptions, but it doesn't have to look like that. There's already an issue to introduce inline catching (#4205) hint hint, nudge nudge.

The problem is that, as @lrhn mentioned, any function can throw, if only due to a stack overflow. But I do not necessarily think this matters: you'd be annotating a method with throws because you are indicating a potentially thrown Exception, not a thrown Error, and a stack overflow is unambiguously an Error. Why would throws force people to handle potential stack overflows when the documentation of Error specifically states that these kinds of errors are not meant to be caught?

These are not errors that a caller should expect or catch - if they occur, the program is erroneous, and terminating the program may be the safest response.

And while I agree that throws would become contagious, it wouldn't nearly be as bad as async: the notion that it's a given that it could contagion its way up to the main function is actually just revealing how rare error handling is done in typical Dart code, and then one has to wonder why that is. It shouldn't be normal for a random int-parse somewhere to crash the entire program, but that's what you get if you never put a try-catch between it and the main function. Importing Swift's throws and try? will do a lot to make Dart code less error prone.

@Wdestroier
Copy link

With union types (#83) you can write something around the lines of:

String | FormatException parseIntStrict(String input) {}

@Protonull
Copy link

With union types (#83) you can write something around the lines of:

String | FormatException parseIntStrict(String input) {}

Those kinds of union types would probably not be a good fit for Dart if only due to the arbitrary-object throwing that it inherited from Javascript. It's not encouraged and there's a linter for it, but it is nonetheless a supported language feature. How would one annotate that method to show that it returns a String or throws a String?

Zig has a particular syntax for this where you could do (in Dart) !String parseIntStrict(String input) {} to show that the function can throw, and FormatException!String parseIntStrict(String input) {} to specify the particular error or error-set that can be thrown.

However, it may be better to just use result types, which are already possible in Dart thanks to sealed classes. But since Dart provides no first-party result type, nor any language features to capture a return/throw into a result, what's left is something like Result.from(() { int.parse("1.2.3"); }); which is not ideal.

@TekExplorer
Copy link

TekExplorer commented May 14, 2025

why shouldnt it be breaking to add a new thrown type? its changing the behavior of the code isn't it? i hate the idea that a package might just decide a function throws all of a sudden in a patch version one pub update later and I have no idea.

its the one thing I actually hate about dart. everything is safe and sound... until it isn't. and I have no way of knowing.

documentation is not the answer, because documentation does not affect code, (and no one documents to this level. we don't have the time for that.) but exceptions literally directly affect everything directly.

this isn't a documentation problem, its a language problem.

if we don't add a throws X, then fine. i understand that it may be too breaking for now, especially if you still don't want adding new exceptions to be a breaking change (for some reason)

at least make @Throws<SpecificException>() a thing, since annotations perfectly fit your requirements.

  • it's not breaking to add an annotation
  • you can add the same annotation multiple times
  • each usage of the annotation could have a separate reason, possibly in the annotation itself

it could, nay should, be as convenient as @override. its arguably just as useful

we've had the argument about checked exceptions before, but we really need something

and it has to be a language thing, or else we simply cant take advantage of it properly. no one is going to use an analyzer plugin unless it gets stupidly popular, which is unlikely.

it neds to be quick-fixable too. any function with an explicit throw should warn about not having @Throws<T>() in it

hell, for the lazy, @throws can exist.

if we wanted to go the extra mile, we could have an @safe for functions we know don't throw, and methods that shouldnt throw.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests