跳转至

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:

img

  • In the top-right of the window, click the dropdown menu next to the Run button and make sure it says Run Without Debugging:

img

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 nullvalues. 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:

img

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:

img

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.

img

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 mainfinishes. 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.

img

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:

img

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:

img

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:

img

  • In the top-right of the window, click the dropdown menu next to the Run button and make sure it says Start Debugging:

img

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.

img

  • 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.

img

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.

img

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()

img

After that, press the Continue button a few times, keeping an eye on the expressions you’re watching on the left.

img

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:

img

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 the try block. Yes, it’s called “throw”, but the meaning is “cause”. If it does throw, the catch 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 use catch (e, s) instead if you need the stack trace, s being the StackTrace 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 it UnsupportedError 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 with catch.
  • 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 to try-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.