Exceptions in Java — exercises with a converter, dates and a bank (and the try I put in the wrong place)

I had to add up the grades of a group of 30 students that came in as text. Since I already knew some could be badly written, I put the loop inside a try and caught the NumberFormatException. The program didn’t crash, it printed “Grade skipped” once… and the sum came out way too low. It took me a while to see it: the try was outside the loop. As soon as the third grade failed, the rest of the try was skipped, and the rest of the try was the entire loop. The other 27 grades were never even looked at.

In these exercises you’ll decide where each try goes, which exception to throw in each case, how to translate a technical error into one people understand, and how to design your own family of exceptions. Before the solutions, three tricky snippets (the first one is exactly my misplaced try). If you need a refresher first, there’s the theory and the practice for this topic.

How to use this article

  1. Read the task and the expected output.
  2. Try it for at least 15 minutes before opening the hint.
  3. Before writing any code, decide for each error: do I throw it or does Java? Do I handle it here or let it go up?
  4. Compare with the solution and read the reasoning behind each decision.

We don’t use Scanner so that the outputs are exact: the “user inputs” are in arrays. The logic is the same as if you read them from the keyboard.

Level 1 — Basic: a safe converter

Task. Create the Converter class with two static methods:

  • int toInt(String text, int defaultValue): converts the text (trimming spaces) to an integer; if it can’t, returns defaultValue.
  • int askBetween(String[] inputs, int min, int max): simulates asking the user for a number. It goes through the inputs in order; for each one that isn’t an integer or is outside [min, max], it prints the reason and moves on to the next. It returns the first valid one. If none is valid, it throws an IllegalStateException with the number of attempts.

Tests and expected output:

System.out.println(toInt("42", 0) + " " + toInt(" 7 ", 0) + " " + toInt("3.5", -1) + " " + toInt("", -1));

System.out.println("Age:");
int age = askBetween(new String[]{"twenty", "-3", "150", " 19 "}, 0, 120);

System.out.println("Grade:");
try {
    askBetween(new String[]{"ten", "11"}, 0, 10);
} catch (IllegalStateException e) {
    System.out.println("  Error: " + e.getMessage());
}
System.out.println("Age saved: " + age);
42 7 -1 -1
Age:
  "twenty": not a whole number
  "-3": out of range [0, 120]
  "150": out of range [0, 120]
  Accepted 19 on attempt 4
Grade:
  "ten": not a whole number
  "11": out of range [0, 10]
  Error: no valid input in 2 attempts
Age saved: 19

In askBetween, the try goes inside the for. The range doesn’t throw any exception on its own: check it with an if and use continue to move on to the next attempt.

Level 2 — Intermediate: a Date that can’t be created wrong

Task. Create the Date class (day, month and year, private final):

  • The constructor throws IllegalArgumentException if the month isn’t between 1 and 12 or if the day doesn’t exist in that month, with a message explaining why (include how many days the month has).
  • static boolean isLeapYear(int year): divisible by 4 and not by 100, or divisible by 400.
  • static int daysInMonth(int month, int year).
  • static Date parse(String text) for texts in dd/mm/yyyy. If there aren’t three parts, throw IllegalArgumentException; if any part isn’t a number, catch the NumberFormatException and throw an IllegalArgumentException instead, with the original as its cause.
  • toString() in the format 12/10/2026.

Tests and expected output:

String[] texts = {"12/10/2026", "29/02/2024", "29/02/2026", "31/04/2026", "15/13/2026", "1-1-2026", "aa/10/2026"};
for (String t : texts) {
    try {
        System.out.println("OK  " + Date.parse(t));
    } catch (IllegalArgumentException e) {
        System.out.println("ERR " + e.getMessage());
        if (e.getCause() != null) {
            System.out.println("    cause: " + e.getCause().getClass().getSimpleName());
        }
    }
}
OK  12/10/2026
OK  29/02/2024
ERR invalid day: 29 (month 2 of 2026 has 28 days)
ERR invalid day: 31 (month 4 of 2026 has 30 days)
ERR invalid month: 13
ERR invalid format: "1-1-2026" (use dd/mm/yyyy)
ERR invalid format: "aa/10/2026" (numbers only)
    cause: NumberFormatException

Exceptions have a constructor IllegalArgumentException(String message, Throwable cause). By passing the original exception as the second argument, whoever catches the new one can look it up with getCause().

Level 3 — Challenge: a bank with its own family of exceptions

Task. Design a bank’s error hierarchy and use it in transfers:

  • BankException extends Exception (checked): the parent of every bank problem.
  • AccountNotFoundException and InsufficientFundsException, both children of BankException. The second one stores how much is missing (getShortBy()).
  • Account with an IBAN and a balance.
  • Bank with an array of accounts and a counter: open(iban, balance), find(iban) (throws AccountNotFoundException) and transfer(from, to, amount) throws BankException.
  • transfer must be atomic: if anything fails, not a single cent moves. Non-positive amounts and transfers to the same account are rejected with IllegalArgumentException.
  • In main, if the problem is the balance, suggest the amount that could be transferred. At the end, check that the bank’s total money hasn’t changed.

Tests and expected output:

Bank bank = new Bank(3);
bank.open("ES01", 500);
bank.open("ES02", 100);
bank.open("ES03", 0);
double before = bank.total();

String[][] orders = {
    {"ES01", "ES02", "200"},
    {"ES02", "ES99", "50"},
    {"ES02", "ES01", "400"},
    {"ES02", "ES02", "10"},
    {"ES01", "ES03", "-5"},
    {"ES02", "ES03", "300"}
};
OK  ES01 -> ES02 (€200)
ERR ES02 -> ES99 (€50): account ES99 does not exist
ERR ES02 -> ES01 (€400): insufficient funds in ES02: short by €100.00. Transfer €300.00 instead?
ERR ES02 -> ES02 (€10): source and destination are the same account
ERR ES01 -> ES03 (€-5): invalid amount: -5.0
OK  ES02 -> ES03 (€300)

Successful: 2 of 6
  ES01: €  300.00
  ES02: €    0.00
  ES03: €  300.00
Total money before: €600.00 · after: €600.00

To make the transfer atomic, do all the checks (find both accounts, look at the balance) before touching any balance. And in main, the InsufficientFundsException catch has to go before the BankException one.

What’s wrong here?

Three snippets that compile. Before reading the explanation, try to predict what they print.

Snippet 1 — The misplaced try

String[] grades = {"8", "6", "seven", "9", "5"};
int sum = 0;
try {
    for (String g : grades) {
        sum += Integer.parseInt(g);
    }
} catch (NumberFormatException e) {
    System.out.println("Grade skipped: " + e.getMessage());
}
System.out.println("Sum: " + sum);

It prints Grade skipped: For input string: "seven" and Sum: 14, when the valid grades add up to 28. It’s my story from the start. When "seven" fails, **the rest of the try** is skipped, and that includes the rest of the loop: the 9 and the 5 are never added. The message says “skipped” once, but three grades were actually skipped. The fix is to move the try inside the for:

for (String g : grades) {
    try {
        sum += Integer.parseInt(g);
    } catch (NumberFormatException e) {
        System.out.println("Grade skipped: " + e.getMessage());
    }
}

Ask yourself “if this fails, what do I want not to run?”. That, and only that, goes inside the try.

Snippet 2 — The finally that changes the value (or not)

static int attempts() {
    int x = 1;
    try {
        return x;
    } finally {
        x = 2;
    }
}

System.out.println(attempts());

It prints 1. The finally does run, and x becomes 2. But the value being returned had already been worked out and stored when return x was reached, before entering the finally. Changing x afterwards doesn’t change what’s already “on its way out”.

“fixing” it by putting return 2; inside the finally. That does change the result, but it also swallows any exception coming from the try: the finally‘s return always wins. Never put a return in a finally; use it only to clean up.

Snippet 3 — The catch that does nothing

static double readGrade(String t) {
    double grade = 0;
    try {
        grade = Double.parseDouble(t);
    } catch (NumberFormatException e) {
    }
    return grade;
}

double average = (readGrade("8") + readGrade("6,5") + readGrade("9")) / 3;
System.out.printf("Average: %.2f%n", average);

It prints Average: 5.67, when it should be 7.83. "6,5" can’t be converted (it has a comma), the empty catch swallows the error and readGrade returns the initial 0 as if it were a grade. Nothing in red, nothing on the console: a false result that looks true.

There are two sensible fixes: accept the comma (t.replace(',', '.')) if that’s a valid format for you, or don’t catch the exception and let it go up to someone who knows what to do. What’s never OK is an empty catch.

Solutions

Level 1 — Safe converter

public class Converter {

    // Tries to convert; if it can't, returns the default value
    static int toInt(String text, int defaultValue) {
        try {
            return Integer.parseInt(text.trim());
        } catch (NumberFormatException e) {
            return defaultValue;                     // here it IS fine not to rethrow: it's the method's contract
        }
    }

    // Simulates asking for a number: goes through the inputs until it finds a valid one in range
    static int askBetween(String[] inputs, int min, int max) {
        for (int i = 0; i < inputs.length; i++) {
            try {
                int n = Integer.parseInt(inputs[i].trim());
                if (n < min || n > max) {
                    System.out.println("  \"" + inputs[i] + "\": out of range [" + min + ", " + max + "]");
                    continue;                        // next attempt
                }
                System.out.println("  Accepted " + n + " on attempt " + (i + 1));
                return n;
            } catch (NumberFormatException e) {
                System.out.println("  \"" + inputs[i] + "\": not a whole number");
            }
        }
        throw new IllegalStateException("no valid input in " + inputs.length + " attempts");
    }

    public static void main(String[] args) {
        System.out.println(toInt("42", 0) + " " + toInt(" 7 ", 0) + " " + toInt("3.5", -1) + " " + toInt("", -1));

        System.out.println("Age:");
        int age = askBetween(new String[]{"twenty", "-3", "150", " 19 "}, 0, 120);

        System.out.println("Grade:");
        try {
            askBetween(new String[]{"ten", "11"}, 0, 10);
        } catch (IllegalStateException e) {
            System.out.println("  Error: " + e.getMessage());
        }
        System.out.println("Age saved: " + age);
    }
}

Why it’s done this way:

  • **toInt catches and returns a default value. Didn’t we say that catching and “carrying on as if nothing happened” was bad? Here it’s different: the method promises**, through its name and parameters, that if it can’t convert it will return defaultValue. The caller knows that and chose it. The problem in snippet 3 was that nobody knew the 0 meant “error”.
  • **"3.5" returns -1:** Integer.parseInt doesn’t accept decimals. If you wanted to round, you’d have to use Double.parseDouble and convert.
  • **try inside the for:** every input gets its chance, the lesson from snippet 1.
  • **The range is checked with if, not with exceptions:** 19 and 150 are perfectly valid integers for Java; being out of range is your rule. continue jumps to the next attempt without running the return.
  • **If the attempts run out, IllegalStateException:** the method can’t keep its promise (return a valid number), so it can’t return anything sensible. Throwing is the only honest option, and it’s unchecked because the situation means the caller passed it insufficient data.

Level 2 — Validated date

public class Date {

    private final int day;
    private final int month;
    private final int year;

    public Date(int day, int month, int year) {
        if (month < 1 || month > 12) {
            throw new IllegalArgumentException("invalid month: " + month);
        }
        int max = daysInMonth(month, year);
        if (day < 1 || day > max) {
            throw new IllegalArgumentException("invalid day: " + day + " (month " + month + " of " + year + " has " + max + " days)");
        }
        this.day = day;                              // only stored if everything is correct
        this.month = month;
        this.year = year;
    }

    public static boolean isLeapYear(int year) {
        return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
    }

    public static int daysInMonth(int month, int year) {
        switch (month) {
            case 2:
                return isLeapYear(year) ? 29 : 28;
            case 4: case 6: case 9: case 11:
                return 30;
            default:
                return 31;
        }
    }

    // "12/10/2026" -> Date. Any problem becomes an IllegalArgumentException
    public static Date parse(String text) {
        String[] parts = text.trim().split("/");
        if (parts.length != 3) {
            throw new IllegalArgumentException("invalid format: \"" + text + "\" (use dd/mm/yyyy)");
        }
        try {
            int d = Integer.parseInt(parts[0]);
            int m = Integer.parseInt(parts[1]);
            int y = Integer.parseInt(parts[2]);
            return new Date(d, m, y);
        } catch (NumberFormatException e) {
            // we translate the technical error into a domain one, and keep the original as the cause
            throw new IllegalArgumentException("invalid format: \"" + text + "\" (numbers only)", e);
        }
    }

    @Override
    public String toString() {
        return String.format("%02d/%02d/%04d", day, month, year);
    }
}

Why it’s done this way:

  • Validating in the constructor guarantees that an invalid Date can’t exist: if the constructor throws, the object never gets created. It’s the “with exceptions” version of setGrade from classes and objects.
  • The month is checked before the day because to know how many days the month has, the month has to be valid. The order of the checks still matters.
  • **parse translates NumberFormatException into IllegalArgumentException.** Whoever uses Date doesn’t care that parseInt is used inside; what matters to them is “the date isn’t valid”. That way, a single catch (IllegalArgumentException e) works for every case.
  • The original exception is kept as the cause (second argument). No information is lost: if someone needs the technical detail, it’s there with getCause().
  • **What if new Date(...) throws inside parse‘s try?** It throws an IllegalArgumentException, which is not a NumberFormatException (it’s its parent, not its child). The catch doesn’t catch it, it goes up unchanged and reaches main with its original message (“invalid day…”). That’s exactly what we want.

2024 is a leap year (divisible by 4 and not by 100) and 2026 isn’t. That’s why 29/02/2024 exists and 29/02/2026 doesn’t. The year 2000 was also a leap year because of the 400 rule; 1900 wasn’t. And calling our class Date is fine as long as you don’t import java.util.Date in the same file.

Level 3 — Bank with its own exceptions

// Parent of every bank problem: catching it catches all its children
public class BankException extends Exception {

    public BankException(String message) {
        super(message);
    }
}
public class AccountNotFoundException extends BankException {

    private final String iban;

    public AccountNotFoundException(String iban) {
        super("account " + iban + " does not exist");
        this.iban = iban;
    }

    public String getIban() { return iban; }
}
public class InsufficientFundsException extends BankException {

    private final double shortBy;

    public InsufficientFundsException(String iban, double shortBy) {
        super(String.format("insufficient funds in %s: short by €%.2f", iban, shortBy));
        this.shortBy = shortBy;
    }

    public double getShortBy() { return shortBy; }
}
public class Account {

    private final String iban;
    private double balance;

    public Account(String iban, double balance) {
        this.iban = iban;
        this.balance = balance;
    }

    public String getIban()     { return iban; }
    public double getBalance()  { return balance; }

    // Only the bank uses these, and only after checking everything
    void take(double amount)    { balance -= amount; }
    void put(double amount)     { balance += amount; }
}
public class Bank {

    private final Account[] accounts;
    private int numAccounts = 0;

    public Bank(int capacity) {
        accounts = new Account[capacity];
    }

    public void open(String iban, double initialBalance) {
        if (numAccounts == accounts.length) {
            throw new IllegalStateException("the bank is full");
        }
        accounts[numAccounts++] = new Account(iban, initialBalance);
    }

    public Account find(String iban) throws AccountNotFoundException {
        for (int i = 0; i < numAccounts; i++) {
            if (accounts[i].getIban().equals(iban)) {
                return accounts[i];
            }
        }
        throw new AccountNotFoundException(iban);
    }

    public void transfer(String from, String to, double amount) throws BankException {
        if (amount <= 0) {
            throw new IllegalArgumentException("invalid amount: " + amount);
        }
        Account a = find(from);                      // we find BOTH before moving a single cent
        Account b = find(to);
        if (a == b) {
            throw new IllegalArgumentException("source and destination are the same account");
        }
        if (amount > a.getBalance()) {
            throw new InsufficientFundsException(from, amount - a.getBalance());
        }
        a.take(amount);                              // from here on nothing can fail
        b.put(amount);
    }

    public double total() {
        double t = 0;
        for (int i = 0; i < numAccounts; i++) {
            t += accounts[i].getBalance();
        }
        return t;
    }

    public void list() {
        for (int i = 0; i < numAccounts; i++) {
            System.out.printf("  %s: €%8.2f%n", accounts[i].getIban(), accounts[i].getBalance());
        }
    }
}
public class Main {
    public static void main(String[] args) {
        Bank bank = new Bank(3);
        bank.open("ES01", 500);
        bank.open("ES02", 100);
        bank.open("ES03", 0);
        double before = bank.total();

        String[][] orders = {
            {"ES01", "ES02", "200"},
            {"ES02", "ES99", "50"},
            {"ES02", "ES01", "400"},
            {"ES02", "ES02", "10"},
            {"ES01", "ES03", "-5"},
            {"ES02", "ES03", "300"}
        };

        int successful = 0;
        for (String[] o : orders) {
            String text = o[0] + " -> " + o[1] + " (€" + o[2] + ")";
            try {
                bank.transfer(o[0], o[1], Double.parseDouble(o[2]));
                successful++;
                System.out.println("OK  " + text);
            } catch (InsufficientFundsException e) {
                System.out.printf("ERR %s: %s. Transfer €%.2f instead?%n",
                        text, e.getMessage(), Double.parseDouble(o[2]) - e.getShortBy());
            } catch (BankException e) {
                System.out.println("ERR " + text + ": " + e.getMessage());
            } catch (IllegalArgumentException e) {
                System.out.println("ERR " + text + ": " + e.getMessage());
            }
        }

        System.out.println("\nSuccessful: " + successful + " of " + orders.length);
        bank.list();
        System.out.printf("Total money before: €%.2f · after: €%.2f%n", before, bank.total());
    }
}

Why it’s done this way:

  • **A common parent (BankException)** gives you two levels of detail. transfer only declares throws BankException, even though it throws two different types; and in main we treat one child in a special way (the funds one, with its suggestion) and the rest of the family all at once. If tomorrow you add AccountFrozenException extends BankException, transfer doesn’t change its signature and main already catches it.
  • **The child’s catch goes before the parent’s.** If BankException came first, it would also catch insufficient funds and the suggestion would never be shown (and Java wouldn’t compile the catch below).
  • Atomicity: all the checks (amount, both accounts, not the same one, the balance) come before take and put. If the destination were looked up after taking the money from the source, the order to ES99 would make €50 disappear. The last line of the output proves it: €600 went in and €600 is left.
  • **take and put have no modifier** (package visibility): Bank uses them, but they’re not meant for the rest of the program. That way nobody can skip transfer‘s checks.
  • **getShortBy() is used to work out the suggestion:** you asked for 400, you’re short by 100, so 300 can be transferred. It’s the useful data the exception carries, like the ATM in the practice article.

Declaring in transfer throws AccountNotFoundException, InsufficientFundsException, ... with every child, one by one. It works, but every new exception forces you to change the signature and every place that calls it. Declaring the parent is cleaner.

Watch it step by step in Python Tutor

Python Tutor runs code line by line and draws the variables and the calls. Let’s watch snippet 2, the finally that “changes” the value, live. Choose Java, paste this and step through it with Next:

public class Tutor {
    static int attempts() {
        int x = 1;
        try {
            return x;
        } finally {
            x = 2;
        }
    }
    public static void main(String[] args) {
        System.out.println(attempts());
    }
}

Output:

1

What to watch:

  1. When it enters attempts, a frame appears with x = 1.
  2. When return x; runs, notice that execution doesn’t leave the method: it jumps to the finally. At that point the 1 has already been decided as the return value, even though the method hasn’t finished yet.
  3. Inside the finally, x changes to 2 in the frame… but the value about to be returned is still 1.
  4. On the way out, main receives that 1. The variable changed; what was being returned didn’t. Now try replacing x = 2; with return 2; and watch how the finally “overrides” the result.

Cheat sheet: exceptions in Java

The essentials in 30 seconds

  • When an exception is thrown in a try, **the rest of the try is skipped**; Java goes into the first catch that matches and then carries on. finally always runs.
  • catch blocks from the most specific to the most general. Child before parent.
  • Checked (Exception, IOException): catch it or declare it with throws. Unchecked (RuntimeException): no obligation.
  • throw new X("message") throws; throws X warns in the signature. Your own exception: class MyException extends Exception + super(message).
  • Never an empty catch: report, fix or rethrow (if you translate, keep the cause).

Syntax

I want to…CodeResult
Try something that might failtry { … } catch (NumberFormatException e) { … }the program carries on
Several types, different handlingseveral catch blocks, specific to generalgoes into the first that matches
Several types, same handlingcatch (NumberFormatException | ArrayIndexOutOfBoundsException e)multi-catch
Something that always runsfinally { … }also with return or break
See what happenede.getMessage()the error text
See the typee.getClass().getSimpleName()"NumberFormatException"
Throw an errorthrow new IllegalArgumentException("…")stops the method
Warn in the signaturevoid f() throws IOExceptionthe caller must handle it
Your own checked exceptionclass X extends Exception { X(String m) { super(m); } }forces handling
Your own unchecked exceptionclass X extends RuntimeExceptionno obligation
Translate keeping the causethrow new IllegalArgumentException("…", e)getCause() returns e
Close a file automaticallytry (BufferedReader r = new BufferedReader(new FileReader(f))) { … }closed on exit

Templates from the exercises

Retry with the try inside the loop (Converter):

for (String t : inputs) {
    try {
        int n = Integer.parseInt(t.trim());
        if (n < min || n > max) continue;
        return n;
    } catch (NumberFormatException e) {
        System.out.println("not a number: " + t);
    }
}
throw new IllegalStateException("no valid input");

Age → accepted 19 on attempt 4

Translating a technical exception (Date):

try {
    return new Date(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]));
} catch (NumberFormatException e) {
    throw new IllegalArgumentException("invalid format: " + text, e);
}

"aa/10/2026" → invalid format…, cause NumberFormatException

A family of exceptions (Bank):

try {
    bank.transfer(from, to, amount);
} catch (InsufficientFundsException e) {      // the child first
    // use e.getShortBy()
} catch (BankException e) {                   // the rest of the family
    System.out.println(e.getMessage());
}

€600 before → €600 after

The most common mistakes

SymptomLikely cause
The program stops with a red stack traceUncaught exception: look for the first line of your code in the trace
unreported exception X; must be caught or declared to be thrownA checked exception with no try/catch or throws
exception X has already been caughtA general catch before a specific one
exception X is never thrown in body of corresponding try statementYou’re catching a checked exception the try can’t throw
Only part of the data gets processedThe try is outside the loop
Odd result with no error on screenAn empty catch, or one that returns a “just in case” value
For input string: "7,5"Decimal with a comma: parseDouble only accepts a dot
empty StringYou tried to convert an empty text
A failed operation leaves data half-changedYou change state before you’ve finished validating

Before you hand it in, ask yourself

  1. Does each try wrap only what mustn’t run if something fails (and is it inside the loop when it should be)?
  2. Do my catch blocks go from most specific to most general, and is none of them empty?
  3. Did I choose well between checked and unchecked, and do my own exceptions carry a clear message and the useful data?
  4. Do I validate everything before changing anything, so an error doesn’t leave data half-changed?
Keep goingAll Java (en) articles

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *