Swift 2.0: throwing vs failing vs crashing vs bool


Updating Aldwych (a JSON parser for Swift), I wrote a method for changing a value in a dictionary that closely echoes the standard library but which has one important parameter added: typesafe.
public mutating func updateValue(value:AnyObject, forKey key:String, typesafe:Bool = true)
So what happens is that if typesafe is set to false any type can be exchanged for any (JSON compatible type) but if typesafe is set to its default (true) and we try to set a different type from the current one then the value won't be updated (unless it was null).

A similar thing happened in Aldwych 1.0, but that was before Swift gained error do-try-catch functionality. There an attempt to change types in typesafe mode resulted in silent failure. The update didn't happen but there was no way of knowing this without checking.

When throwing and catching doesn't fit

With the new ability to throw and catch errors it seemed at first an ideal solution for Aldwych's type safety that an error should be thrown. But actually that creates a whole load of code every time a value is updated. And things ended up looking like this:


It isn't terrible but as soon as I got to the point where I was writing
try! a.updateValue("something", forKey:"whatever", typesafe:false)
it felt like the error handling was running counter to the method. The question was should I write an additional method and break typesafe and non-typesafe into two? The answer I arrived at was no.

If we're going to use type safety then let's use it properly, there's no excuse for a developer to stab in the dark at guessing types (even with JSON). If they don't care about type safety in the JSON they can use false. If they do care, then they need to make sure that all types remain consistent otherwise ... crash.

This might seem brutal but it actually saves a lot of code because all that needs to happen is for the developer to check ahead of time the type if they are uncertain.
if a["One"]?.canReplaceWithBool() == true {
    a.updateValue(false, forKey: "One") // default for typesafe is true
}
The alternative to this brutality would be to use a bool to confirm or deny that an update has taken place. (And in fact it might be that I add this return value at a later stage in the development of Aldwych.) But the programmer can be confident that any attempt to change a type for type value will succeed in this no crash, no problem approach. And if people implementing the library don't want this functionality it can be turned off by commenting out a couple of lines of code (and no changes to the implementation). Whereas if we implement throwing behaviour and then wish to change this to non-throwing behaviour there's a whole load of surrounding code that needs to be dealt with, not only in the library but in the implementation.

When throwing and catching feels right

Working with throw and catch, I had a completely different feeling when parsing the JSON data. I wrote a method that takes a file name (and one that takes a URL string), and this method does throw. So we implement it as follows:
var jsonValue:JSONDictionary?

do {
    jsonValue = try JSONParser.parse(fileNamed: "iTunes.json") as? JSONDictionary
}
catch let e  {
    errorString(error: e)
}
But the hidden beauty of this function is that it first looks for the file, then transforms the data into objects, and then parses it. And at each stage an error might be thrown, but because each error can be forwarded (between the series of methods contained in the multi-stage operation) the implementation only needs one do-try-catch statement for all three of these things to happen.

You'll see from the chain of methods that I've used to load and parse the data how errors are forwarded:
public static func parse(fileNamed fileName:String) throws -> JSONObjectType {
        let pE = fileName.pathExtension
        let name = fileName.stringByDeletingPathExtension
        guard let url = NSBundle.mainBundle().URLForResource(name, withExtension: pE) else {
        throw JSONError.FileError("File not found with name \(fileName) in main bundle.")
        }
        return try parse(url)
    }
    
    public static func parse(url:NSURL) throws -> JSONObjectType {
        guard let d = NSData(contentsOfURL: url) else {
        throw JSONError.DataError("Failed to retrieve data from NSURL.")
        }
        return try parse(d)
    }
    
    public static func parse(json:NSData) throws -> JSONObjectType {

        guard let jsonObject: AnyObject? = try NSJSONSerialization.JSONObjectWithData(json, options:[]) else {
        throw JSONError.JSONValueError("NSJSONSerialization returned nil.")
        }

        if let js = jsonObject as? [String:AnyObject] {
                let a = JSONDictionary(dictionary: js)
                return a
        }
        else if let js = jsonObject as? [AnyObject] {
                return JSONArray(array: js)
        }
        throw JSONError.JSONValueError("Not a valid dictionary or array for parsing into JSONValue.")
        
    }
And also from the function I use to process the caught error:
public func errorString(error err:ErrorType) -> String {
    if let e = err as? JSONError {
        switch e {
        case .FileError (let error):
            return error
        case .DataError (let error):
            return error
        case .JSONValueError (let error):
            return error
        case .URLError(let error):
            return error
        }
    }
    
    else {
        return (err as NSError).description
    }
    
}
The way this code works is one of my favourite things about the Aldwych update. It saves separation and repetition. Errors are passed from one method to the next and if any throw an error that error is passed up the chain and we know exactly where that error occurred, whether it's an NSError or one generated by Aldwych directly.

Conclusion

In the context of Aldwych, I've found that it is appropriate to throw errors when something occurs that it is within the user's ability to fix if forwarded an error message. For example, if a file or url doesn't exist, or if a file fails to load or parse, then it suggests there is corruption or the file is missing. To this the user has an easy solution: choose another file. And assuming the app doesn't mess up on file loading in a normal situation all will be fine. But if the app fails to replace type with type, then this is nothing to do with the user, instead it is the app which is faulty and risks not only leaving data in an inconsistent and unpredictable state, but it risks returning data to a server that is not as the user expects. This is not acceptable. If type safety is required, type safety needs to be delivered and a developer needs to make sufficient checks to make sure this occurs and their app doesn't crash.

The human element must also be considered. If we have too many types of operation that can throw errors then we force developers implementing libraries like Aldwych to have lots of do-try-catch statements, which can not only visually bloat code, but risks driving developers to lazily use try! (similar to what we've seen with optionals) or worse catch errors but do nothing at all with them (which is the equivalent of passing nil to an NSError parameter, and who hasn't done that?).


Endorse on Coderwall

Comments