10 Error Handling¶
It’s natural to only code the happy path as you begin to work on a project.
- “This text input will always be a valid email address.”
- “The internet is always connected.”
- “That function’s return value is always a positive integer.”
Until it isn’t.
When a baker makes a mistake, cookies get burned. When an author makes a mistake, words get mispelled. Eaters will swallow and readers overlook, but code doesn’t forgive. Maybe you noticed misspelled was “mispelled” in the previous sentence. Or maybe you missed it. We all make mistakes, but when a programmer makes one, the whole app crashes. That’s the nature of a programmer’s work. The purpose of this chapter is to teach you how to crash a little less.
Errors and Exceptions in Dart¶
The creators of Dart had such confidence you and your users would make mistakes that they built error handling right into the language. Before you learn how to handle errors, though, you get to make them!
How to Crash an App¶
You have many ways to cause your application to give up all hope of survival and quit. Open a new project and try out a few of them.
Dividing by Zero¶
One way to crash is to divide by zero. You learned in elementary school that you can’t do that.
Write the following code in main
:
1 ~/ 0;
Remember, ~/
is for integer division. The expression 1 / 0
is floating-point division and would give you a result of double.infinity
without crashing.
Now, run your code without debugging. There are a few ways to do that:
- Choose Run ▸ Run Without Debugging from the menu.
- Click Run, not Debug, above the
main
method:
- In the top-right of the window, click the dropdown menu next to the Run button and make sure it says Run Without Debugging:
Note
You’ll learn about debugging later in this chapter, but until directed to do differently, run all the examples here without debugging. That way, VS Code won’t pause when it reaches an error.
After running the program, check the debug console to see an error message that begins with the following two lines:
Unhandled exception:
IntegerDivisionByZeroException
IntegerDivisionByZeroException
is Dart’s name for what happened. An exception is something outside of the usual rules. Dividing by an integer is normal, but dividing by zero is an exceptional case. Even though it’s exceptional, you’re still expected to know and plan for it. Not handling an exception is an error. And errors crash your app.
Note
Sometimes you’ll see the words “error” and “exception” used interchangeably, but strictly speaking, an exception is typical and good when properly handled, whereas an error is bad.
No Such Method¶
In the past, you often got a NoSuchMethodError
when you forgot to handle null
values. Because the Null
class doesn’t have many methods, almost anything you tried to do with null
caused this crash.
After Dart added sound null safety, NoSuchMethodErrors
became far less common. You can still see what it looks like by turning off type checking using dynamic
.
Write the following in main
:
dynamic x = null;
print(x.isEven);
Unlike integers, null
doesn’t have an isEven
getter method. So when you run that code, you get the following message:
Unhandled exception:
NoSuchMethodError: The getter 'isEven' was called on null.
The error you got here was a runtime error. Dart didn’t discover it until you ran the code.
Change dynamic
to int
in the code above:
int x = null;
print(x.isEven);
Now, you have a compile-time error. VS Code puts little red squiggles under null
with the message that you’re not allowed to do that:
A value of type 'Null' can't be assigned to a variable of type 'int'.
Compile-time errors are much better than runtime errors because they’re obvious and immediate.
Format Exception¶
Another way to crash your app is to try and decode a “JSON” string that isn’t actually in JSON format.
Replace the contents of your project file with the following code:
import 'dart:convert';
void main() {
const malformedJson = 'abc';
jsonDecode(malformedJson);
}
The string 'abc'
isn’t in JSON format, so when you try to decode it, you get the following error message:
Unhandled exception:
FormatException: Unexpected character (at character 1)
abc
^
This is a format exception, which Dart identifies with the FormatException
class.
Another way to cause a format exception is to try to turn a non-numeric string into an integer:
int.parse('42'); // OK
int.parse('hello'); // FormatException
The first line is fine. It converts the string '42'
into the integer 42
. However, Dart has no idea how to convert the string 'hello'
into an integer, so it stops executing the program with the following error:
Unhandled exception:
FormatException: Invalid radix-10 number (at character 1)
hello
^
“Radix-10” means base-10 or decimal, as opposed to binary or hexadecimal numbers, which parse
also supports. hello
wouldn’t work in binary or hex either, but come to think of it, DAD
, FED
, BEEF
, DEAD
, C0FFEE
would parse in hex.
There are many other ways to crash your app. But hopefully, the examples above gave you a taste of how to do it. As if anyone needed help with this kind of thing.
Reading Stack Traces¶
The sections above only gave you part of the error messages. You probably noticed that the full message was much longer and a lot of it looked like unintelligible gibberish. The unintelligible part is called a stack trace:
A stack trace is a printout of all the methods on the call stack when an error occurs. In the stack trace above, most methods are internal. #4 main
is the only one that’s part of your code.
Do you remember the stack data structure you made for the challenges in Chapter 8, “Generics”? Well, it turns out stacks are pretty important in computer science. Computers use them to keep track of the current method being executed.
To see this more clearly, write some functions that call other functions:
void main() {
functionOne();
}
void functionOne() {
functionTwo();
}
void functionTwo() {
functionThree();
}
void functionThree() {
int.parse('hello');
}
When Dart executes this program, it’ll start by calling main
. Because main
is the current function, Dart adds main
to the call stack. You can think of a stack like a stack of pancakes. main
is the first pancake on the stack. Then, main
calls functionOne
, so Dart puts functionOne
on the call stack. functionOne
is the second pancake on the stack. functionOne
calls functionTwo
, and functionTwo
calls functionThree
. Each time you enter a new function, Dart adds it to the call stack.
Normally, when functionThree
finishes, Dart would pop it off the top of the stack, go back to functionTwo
, finish functionTwo
, pop it off the stack and so on until main
finishes. However, in this case, there’s about to be a tragedy in functionThree
, which will bring everything to a grinding halt.
Run the code you just wrote. The app crashes when you reach the line int.parse('hello');
. Look at the debug console, and you’ll see the stack trace that shows the call stack at the time of the crash.
There, your four methods sit in the middle of the stack. The other methods above and below them are internal to Dart. On the right side, you can see bin/starter.dart followed by a line number. Yours might look different if your project name is different. Click the one after functionThree
, and VS Code brings you to the line number where the crash occurred in functionThree
, at int.parse('hello');
.
Stack traces look messy and intimidating, but they’re your friends. They hold some of the first clues to what went wrong.
Debugging¶
It’s not always obvious from the stack trace where the bug in your code is. VS Code has good debugging tools to help you out in this situation.
Writing Some Buggy Code¶
Replace your project file with the following code:
void main() {
final characters = ' abcdefghijklmnopqrstuvwxyz';
final data = [4, 1, 18, 20, 0, 9, 19, 0, 6, 21, 14, 27];
final buffer = StringBuffer();
for (final index in data) {
final letter = characters[index];
buffer.write(letter);
}
print(buffer);
}
First, run the code without debugging. You’ll get a crash with the following error message:
Hmm, what does that mean?
The stack trace tells you the error happened in the main
method on line 6, which is the following line:
final letter = characters[index];
That line looks OK — no division by zero or trying to parse a weird string.
To find the error, you’ll use the debugging tools available in VS Code and step through your code line by line.
Adding a Breakpoint¶
Click the margin to the left of line 2. This will add a red dot:
That red dot is called a breakpoint. When you run your app in debug mode, execution will pause when it reaches that line.
Running in Debug Mode¶
Now, start your app in debug mode. Like before, there are a few ways to do that:
- Choose Run ▸ Start Debugging from the menu.
- Click Debug above the
main
method:
- In the top-right of the window, click the dropdown menu next to the Run button and make sure it says Start Debugging:
Stepping Over the Code Line by Line¶
VS Code pauses execution at line 2. Then, a floating button bar pops up with various debugging options. If you hover your mouse over each button, you can see what it does. The most important ones for now are the two on the left:
- Continue: This is the button with the line and triangle. It resumes normal execution until the next breakpoint, if any, is reached.
- Step Over: This is the clockwise arrow over the dot. Pressing it executes one line of code but doesn’t descend into the body of any function it reaches. That’s fine because you don’t have other functions besides
main
in this example.
Note
Later, when you’re debugging an app with functions, use the Step Intobutton, the one with the arrow pointing down at the dot, to enter the body of another function. For example, if you wanted to follow the logic of the recursive functions in Chapter 8, “Generics”, you would use this button.
Press the Step Over button several times until execution reaches line 7: buffer.write(letter);
.
Look at the Run and Debug panel on the left. The Variables section shows the current values of the variables in your code.
Keep pressing Step Over for a few more iterations of the for
loop while keeping an eye on the values of the variables. You’ll begin to see the pattern of how the code works. Stepping through code one line at a time like this will often help you discover even the hardest-to-find bugs.
Watching Expressions¶
When you tire of stepping one line at a time through the for
loop, add a breakpoint to line 6: final letter = characters[index];
.
Then, find the Watch section on the Run and Debug panel. Add the following two expressions by pressing the + button:
characters[index]
buffer.toString()
After that, press the Continue button a few times, keeping an eye on the expressions you’re watching on the left.
Fixing the Bug¶
When the app finally crashes, what’s the value of index
?
It’s 27
. What’s the length of the characters
string? If you don’t want to count, add characters.length
to the Watch window. It’s also 27
.
Ah, that’s it! You recall that lists and string indexes are 0-based, so index 27
is one greater than the last position in the list. That was causing the range error.
You remember that you wanted to end the message with an exclamation mark but forgot to add it to characters
.
If your code is still running, press the Stop button:
Then, replace line 2 with the following:
final characters = ' abcdefghijklmnopqrstuvwxyz!';
Note the !
at the end of the string.
Now, rerun the code without debugging.
No errors this time! You see the following output in the debug console:
dart is fun!
If you want to remove the breakpoints, click the red dots on the left of lines 2 and 6.
Handling Exceptions¶
The bug in the last section was an actual error in the logic of the code. There was no way to handle that except to track down the bug and fix it.
Other types of crashes, though, are caused by valid exceptions that you aren’t properly handling. You need to think about these and how to deal with them.
Catching Exceptions¶
As you saw earlier, one source of these exceptions is invalid JSON. When connected to the internet, you can’t control what comes to you from the outside world. Invalid JSON doesn’t happen very often, but you should write code to deal with it when you get it.
Replace your project file with the following code:
import 'dart:convert';
void main() {
const json = 'abc';
try {
dynamic result = jsonDecode(json);
print(result);
} catch (e) {
print('There was an error.');
print(e);
}
}
Here are a few comments:
- In Chapter 12, “Futures”, you’ll learn how to retrieve JSON strings from the internet. For now, though, you’re just using a hard-coded string for the JSON.
- The new code is a
try-catch
block. You put the code that might throw an exception in thetry
block. Yes, it’s called “throw”, but the meaning is “cause”. If it does throw, thecatch
block will handle the exception without crashing the app. In this case, all you’re doing is printing a message and the error. - Here,
e
is the error or exception. You can usecatch (e, s)
instead if you need the stack trace,s
being theStackTrace
object.
Run the code, and you’ll see the following result:
There was an error.
FormatException: Unexpected character (at character 1)
abc
^
Unlike when you had a FormatException
earlier in the lesson, this time, the app didn’t crash. You handled this exception.
Note
In a real app, you’d want to do more than print the error message. In fact, you should remove print
statements from production apps because they can sometimes leak sensitive data.
How to handle this particular exception would depend on the context. Say a user requested to see some song lyrics, so your app asked the server for them but got back invalid JSON. What should you do in this case? Probably, you’d want to notify the user that the song lyrics they requested aren’t currently available.
In the code above, replace this line:
const json = 'abc';
With the following line containing a valid JSON string:
const json = '{"name":"bob"}';
Then, rerun the code. This time, the try
block finishes successfully, and you see the Dart map that jsonFormat
produced:
{name: bob}
Handling Specific Exceptions¶
Using a catch
block will catch every exception that happens in the try
block.
“Perfect!” you say. “I’ll just wrap my entire app in one big try-catch
block. No more crashes!”
Even though that might sound like a good idea, try-catch
isn’t magic. It doesn’t make the problems go away. In fact, sometimes, it can make things worse because you’re hiding your problems rather than dealing with them. Sometimes coding can be like real life, can’t it?
Just as you can’t solve your life troubles all at once, you can’t handle every programming exception with one catch
block. It’s better to focus on one problem at a time, both in life and in coding.
To catch a specific exception, use the on
keyword. Replace the body of main
with the following code:
const json = 'abc';
try {
dynamic result = jsonDecode(json);
print(result);
} on FormatException {
print('The JSON string was invalid.');
}
Now, it’s very clear that you’re only handling format exceptions.
Run the code, and you’ll see the expected message:
The JSON string was invalid.
You’re handling format exceptions, but if there are any other exceptions, your app will still crash.
“What?” you say. “I don’t want my app to crash! Please just let me use a catch
block.”
If you don’t know what you’re catching, how can you handle it? After all, the solution to no internet is quite different than the solution to a range error. If the app crashes, that’s a good thing. It’s a loud and clear signal that there’s an exceptional situation happening that you need to know about.
Handling Multiple Exceptions¶
When there’s more than one potential exception that could occur, you can use multiple on
blocks to target them.
Replace your project file with the following example:
void main() {
const numberStrings = ["42", "hello"];
try {
for (final numberString in numberStrings) {
final number = int.parse(numberString);
print(number ~/ 0);
}
} on FormatException {
handleFormatException();
} on UnsupportedError {
handleDivisionByZero();
}
}
void handleFormatException() {
print("You tried to parse a non-numeric string.");
}
void handleDivisionByZero() {
print("You can't divide by zero.");
}
Here are some notes:
- This time, you’re handling two possible errors. The extra functions emphasize that you can break your handling code into separate logical units.
IntegerDivisionByZeroException
is deprecated and will probably be removed from the language in the future. That doesn’t mean you’ll be able to divide by zero in the future. It just means you should call itUnsupportedError
when catching such an exception.
Run the code above to see the result:
You can't divide by zero.
The code in the try
block terminates as soon as you hit the first error. You never made it to the format exception. But you were ready for it.
The Finally Block¶
There’s also a finally
block you can add to your try-catch
structure. The code in that block runs both if the try
block is successful and if the catch
or on
block catches an exception.
Replace the contents of main
with the following example:
void main() {
final database = FakeDatabase();
database.open();
try {
final data = database.fetchData();
final number = int.parse(data);
print('The number is $number.');
} on FormatException {
print("Dart didn't recognize that as a number.");
} finally {
database.close();
}
}
class FakeDatabase {
void open() => print('Opening the database.');
void close() => print('Closing the database.');
String fetchData() => 'forty-two';
}
FakeDatabase
represents a situation where you must clean up some resources even if the operation in the try
block is unsuccessful. Note that you “close” the database in the finally
block.
Run the code to see the result:
Opening the database.
Dart didn't recognize that as a number.
Closing the database.
The try
block failed because parsing forty-two
threw a format exception. Even so, the database still had an opportunity to close.
Now, replace 'forty-two'
in the code above with '42'
. Then, rerun the program.
This time, you’ll see:
Opening the database.
The number is 42.
Closing the database.
The try
block was successful, and the finally
block also ran its code.
Writing Custom Exceptions¶
You should use the standard exceptions whenever you can, but you can also define your own exceptions when appropriate.
Back in Chapter 1, “String Manipulation”, you learned how to validate passwords with regular expressions. You’ll build on that foundation now by defining some custom exceptions for invalid passwords.
Defining the Exceptions¶
First, create the following Exception
class for passwords that are too short:
class ShortPasswordException implements Exception {
ShortPasswordException(this.message);
final String message;
}
As you can see, it’s pretty easy to make a custom exception. All you need to do is implement Exception
and create a field that will hold a message describing the problem.
Create a few more exceptions for other password problems:
class NoNumberException implements Exception {
NoNumberException(this.message);
final String message;
}
class NoUppercaseException implements Exception {
NoUppercaseException(this.message);
final String message;
}
class NoLowercaseException implements Exception {
NoLowercaseException(this.message);
final String message;
}
Those are exceptions you can throw if the potential password doesn’t include a number, uppercase letter or lowercase letter.
Throwing Exceptions¶
Now, add the following validation method below main
:
void validateLength(String password) {
final goodLength = RegExp(r'.{12,}');
if (!password.contains(goodLength)) {
throw ShortPasswordException('Password must be at least 12 characters!');
}
}
Use the throw
keyword when you want to throw an exception. You can throw anything. For example, you could even throw a string and it would halt program execution if you didn’t handle it:
throw 'rotten tomatoes';
But it’s better that you throw classes that implement Exception
.
Add a few more validation methods below main
:
void validateLowercase(String password) {
final lowercase = RegExp(r'[a-z]');
if (!password.contains(lowercase)) {
throw NoLowercaseException('Password must have a lowercase letter!');
}
}
void validateUppercase(String password) {
final uppercase = RegExp(r'[A-Z]');
if (!password.contains(uppercase)) {
throw NoUppercaseException('Password must have an uppercase letter!');
}
}
void validateNumber(String password) {
final number = RegExp(r'[0-9]');
if (!password.contains(number)) {
throw NoNumberException('Password must have a number!');
}
}
Those throw the other exceptions you made.
Now, create one more validation function to combine the others:
void validatePassword(String password) {
validateLength(password);
validateLowercase(password);
validateUppercase(password);
validateNumber(password);
}
You could have put all the earlier validation logic right here in this function. However, the Single Responsibility Principle says that a function should do only one thing. Extracting code into short and simple functions makes the logic easier to reason about. Writing clean code is a step in the right direction toward preventing errors. And preventing errors is better than handling them!
Handling Custom Exceptions¶
Now that you have all the exceptions defined and the validation logic set up, you’re ready to use them. Replace the contents of main
with the following code:
const password = 'password1234';
try {
validatePassword(password);
print('Password is valid');
} on ShortPasswordException catch (e) {
print(e.message);
} on NoLowercaseException catch (e) {
print(e.message);
} on NoUppercaseException catch (e) {
print(e.message);
} on NoNumberException catch (e) {
print(e.message);
}
In addition to demonstrating how to use your custom exceptions, this example shows that you can combine the on
and catch
keywords. The e
is an instance of your custom exception class, giving you access to the message
property you defined.
Run the code to see the message:
Password must have an uppercase letter!
Play around with the password to confirm that the other exceptions work as well.
Challenges¶
Before moving on, here are some challenges to test your knowledge of error handling. It’s best if you try to solve them yourself, but solutions are available with the supplementary materials for this book if you get stuck.
Challenge 1: Double the Fun¶
Here’s a list of strings. Try to parse each of them with double.parse
. Handle any format exceptions that occur.
final numbers = ['3', '1E+8', '1.25', 'four', '-0.01', 'NaN', 'Infinity'];
Challenge 2: Five Digits, No More, No Less¶
- Create a custom exception named
InvalidPostalCode
. - Validate that a postal code is five digits.
- If it isn’t, throw the exception.
Key Points¶
- An error is something that crashes your app.
- An exception is a known situation you must plan for and handle.
- Not handling an exception is an error.
- A stack trace is a crash report that tells you the method and line that crashed your app.
- VS Code debugging tools allow you to set breakpoints and execute your code one line at a time.
try/catch
blocks are one way to handle exceptions.- It’s better to handle specific exceptions with the
on
keyword rather than blindly handling all exceptions withcatch
. - If you have a logic error in your app, don’t “handle” it with
catch
. Let your app crash and then fix the bug. - Add a
finally
block totry-catch
if you need to clean up resources. - You can create custom exceptions that implement
Exception
.
Where to Go From Here?¶
It’s a good thing when your app crashes while developing it. That’s a signal of something you need to fix. But when your app crashes for your users after you’ve published it, that’s not such a good thing. Some people might email you when they find a bug. Others might leave a negative review online, but most users won’t tell you about crashes or bugs. For that reason, you might consider using a third-party crash reporting library in your app. It’ll collect crash reports in a central location. Analyzing those reports will help you find and fix bugs you wouldn’t otherwise know about.
Another important topic you should learn about is unit testing. Unit testing is where you write code to test your app’s individual units of logic. These units are usually classes or functions. Systematic testing ensures that all the logic in your app behaves as expected. Going through this process will not only help you discover hidden bugs, it’ll also keep you from breaking things in the future that used to work in the past. That’s called a regression bug.