Search code examples
swifttry-catchabstraction

How do I move try-catch into a class that handles all try-catch logic but also return errors?


I am writing a basic player wallet management class where Integers are added (credited) or subtracted (debited) to a player's wallet where errors are caught by a simple try-catch logic.

Note: It only is meant to handle Integers.

I am attempting to abstract away the try-catch logic into this Wallet class; it should handle anything with credit, debit actions, capture errors and return errors if any found.

Is there a way to move try-catch stuff into a class and yet returns errors?

Lets go through this example:

My current class is as such:

enum WalletError : Error {
    case mustBePositive
    case notEnoughFunds
}

class Wallet {
    public private(set) var balance: Int = 0

    init(amount: Int = 0) {
        self.balance = amount
    }

    func credit(amount: Int) throws {
        guard amount > 0 else {
            throw WalletError.mustBePositive
        }
        handleCredit(amount: amount)
    }

    func debit(amount: Int) throws {
        guard amount > 0 else {
            throw WalletError.mustBePositive
        }
        guard balance >= amount else {
            throw WalletError.notEnoughFunds
        }
        guard (self.balance - amount >= 0) else {
            throw WalletError.notEnoughFunds
        }
        handleDebit(amount: amount)
    }

    // MARK: (Private)

    private func handleCredit(amount: Int) {
        self.balance += amount
    }

    private func handleDebit(amount: Int) {
        self.balance -= amount
    }
}

Testing the debit actions, I have this XCTest function

func testDebit() {
    let w = Wallet()

    XCTAssertTrue(w.balance == 0)

    // Can't debit negative numbers
    XCTAssertThrowsError(try w.debit(amount: -100)) { error in
        XCTAssertEqual(error as? WalletError, WalletError.mustBePositive)
    }

    // can't debit money you don't have
    XCTAssertThrowsError(try w.debit(amount: 100)) { error in
        XCTAssertEqual(error as? WalletError, WalletError.notEnoughFunds)
    }

    // credit $100
    XCTAssertNoThrow(try w.credit(amount: 100))

    // debit $0 should throw error
    XCTAssertThrowsError(try w.debit(amount: 0)) { error in
        XCTAssertEqual(error as? WalletError, WalletError.mustBePositive)
    }

    // debit > balance should throw error
    XCTAssertThrowsError(try w.debit(amount: 101)) { error in
        XCTAssertEqual(error as? WalletError, WalletError.notEnoughFunds)
    }

    // debit $1 should be successful
    XCTAssertNoThrow(try w.debit(amount: 1))

    // balance should be 100-1 = $99
    XCTAssertTrue(w.balance == 99)
}

The problem I'm having here is:

When I write the application, I need to write the try-catch block every time.

I would like the Wallet class to handle all try-catch stuff, and only report errors

I tried this:

// changes to wallet class
func canCredit(amount: Int) throws -> Bool {
    guard amount > 0 else {
        throw WalletError.mustBePositive
    }
    return true
}

func credit(amount: Int) -> Error? {
    do {
        if ( try canCredit(amount: amount) ) {
            handleCredit(amount: amount)
        }
        return nil
    } catch let error {
        return error
    }
}

But now the XCTest doesn't know about the throwing, and I would need to test the canCredit which now only returns true/false.

Further, the credit attempts to return the error, but I can't use XCTAssertThrowsError()

Is there a way where I can:

  1. The Wallet class should only be responsible for credit, debit and validation

  2. Throw all my try-catch stuff logic into this class so I don't have to repeat the try-catch stuff all the time?

I hope I've clarified the issue.

With thanks


Solution

  • Your goal seems to be to take a thrown error and transform it into a returned error. That goal seems wrong. Throwing is a form of flow control. Throwing an error is how you "return an error" to the caller. You shouldn't want, in the first place, the thing you seem to want.

    Still, you can certain do it. First, just keep your credit(amount:) and debit(amount:) methods exactly as they already are in your first code example, so that your unit tests can test them. Second, in your "real code", don't call those methods. Instead, call additional "wrapper" methods that you supply, that catch the thrown error and transform it into a returned error.

    For example:

    func realcredit(amount: Int) -> Error? {
        do {
            try credit(amount:amount)
            return nil
        } catch {
            return error
        }
    }