Errors, Exceptions and Failing on Purpose

Article · 12:00

Dart splits failure into two families and the distinction is the whole lesson: an Exception is a condition the program should expect and may recover from, an Error is a bug. With rethrow that keeps the stack, and when the answer is a value rather than a throw.

Errors, Exceptions and Failing on Purpose

Dart splits failure into two families, and the distinction is the whole lesson.

  • An Exception is a condition the program should expect and may recover from: a malformed file, a refused connection, a number that would not parse.
  • An Error is a bug: a null assertion that failed, an index off the end of a list, an argument that violates a documented precondition. You fix these, you do not catch them.
void main() {
  // An Exception - the input was wrong, and the caller can react.
  try {
    int.parse('twelve');
  } on FormatException catch (e) {
    print('Exception: ${e.message}');
  }

  // An Error - the code was wrong. Catching this hides a bug.
  try {
    <int>[1, 2, 3][7];
  } on RangeError catch (e) {
    print('Error: ${e.message}');
  }
}

Dart does not have checked exceptions, so nothing forces a caller to handle anything. That makes what you throw a documentation decision as much as a technical one.

try, on, catch, finally

String load(String key) {
  if (key.isEmpty) throw ArgumentError.value(key, 'key', 'must not be empty');
  if (key == 'missing') throw StateError('no record for $key');
  return 'value for $key';
}

void main() {
  for (final key in ['ok', 'missing', '']) {
    try {
      print(load(key));
    } on StateError catch (e) {
      print('state: ${e.message}');
    } on ArgumentError catch (e, stackTrace) {
      // The second variable is the stack trace, and it is the useful half.
      print('argument: ${e.message}');
      print('  raised ${stackTrace.toString().split('\n').first.trim()}');
    } catch (e) {
      print('anything else: $e');      // no `on` clause: catches everything
    } finally {
      print('  ...done with "$key"');  // runs whether or not it threw
    }
  }
}

Order matters: the first matching on wins, so specific types go above general ones. A bare catch (e) at the end catches everything including Error, which is almost never what you want in library code.

Your own exception types

A useful exception carries the data needed to explain itself, and implements Exception so it reads as one.

class ValidationException implements Exception {
  final String field;
  final String reason;

  const ValidationException(this.field, this.reason);

  @override
  String toString() => 'ValidationException: $field $reason';
}

class Registration {
  final String email;
  final int age;

  Registration(this.email, this.age) {
    if (!email.contains('@')) {
      throw const ValidationException('email', 'must contain @');
    }
    if (age < 13) {
      throw const ValidationException('age', 'must be at least 13');
    }
  }
}

void main() {
  for (final input in [('ada@example.com', 36), ('nope', 30), ('a@b.com', 9)]) {
    try {
      Registration(input.$1, input.$2);
      print('${input.$1}: accepted');
    } on ValidationException catch (e) {
      print('${input.$1}: rejected - ${e.field} ${e.reason}');
    }
  }
}

rethrow keeps the original stack trace

When you catch something only to log it, rethrow sends the original exception onward with its stack trace intact. throw e starts a new one from here, which loses the line where it actually went wrong.

int parseAge(String raw) {
  try {
    return int.parse(raw);
  } on FormatException {
    print('  (logging a bad age: "$raw")');
    rethrow;                     // the caller still sees the original failure
  }
}

void main() {
  try {
    parseAge('thirty');
  } on FormatException catch (e) {
    print('caller handled: ${e.message}');
  }
}

Often the answer is not to throw at all

An expected outcome is a value, not an exception. Dart's own library shows the pattern: int.parse throws, int.tryParse returns null.

void main() {
  const inputs = ['10', 'twelve', '30'];

  // Throwing version, with the ceremony that comes with it.
  var total = 0;
  for (final input in inputs) {
    try {
      total += int.parse(input);
    } on FormatException {
      // ignored
    }
  }
  print(total);

  // The same thing as a value, which reads better and costs less.
  final sum = inputs
      .map(int.tryParse)
      .whereType<int>()
      .fold<int>(0, (a, b) => a + b);
  print(sum);
}

A useful rule: throw when the caller has made a mistake or when continuing would be wrong, return a value when the outcome is one of several the caller expects.

assert is for development, and disappears in production

double average(List<int> values) {
  assert(values.isNotEmpty, 'average of nothing is undefined');
  return values.reduce((a, b) => a + b) / values.length;
}

void main() {
  print(average([2, 4, 6]));

  // Asserts are OFF by default, including under `dart run`. Turn them on with
  // `dart run --enable-asserts`; Flutter enables them in debug mode; every
  // release build strips them entirely.
  try {
    print(average([]));
  } on AssertionError catch (e) {
    print('asserts are on: ${e.message}');
  } on StateError catch (e) {
    print('asserts are off, so reduce failed instead: ${e.message}');
  }
}

Run that twice - once plainly, once with --enable-asserts - and the two different failures are the point. With asserts off, the expectation you wrote does nothing and the code fails further in, somewhere less obvious.

So validate arguments that come from outside with a real check and a thrown ArgumentError. Use assert for the things you believe are true about your own code.

What to take away

  • Exception is an expected condition; Error is a bug. Catch the first kind.
  • on Type catch (e, stackTrace) gives you the trace, which is the useful half.
  • Put specific on clauses above general ones; a bare catch also catches bugs.
  • rethrow preserves the original stack trace; throw e discards it.
  • Prefer a value (tryParse, a nullable return) for outcomes the caller expects.
  • assert is off unless enabled, and stripped in release builds - never use it to validate input.