I’m a Rust programmer and in general a fan of strong typing over dynamic or duck typing. But a lot of advocacy for strong typing doesn’t actually give examples of the bugs it can prevent, or it gives overly simplistic examples that don’t really ring true to actual experience.

Today, I have a longer-form example of where static typing can help prevent bugs before they happen.

The Problem#

Imagine you have a process that receives messages and must respond to them. In fact, imagine you have potentially many such processes, and want to write a framework to handle it.

The incoming messages are expected to be in JSON, and the responses are also supposed to be in JSON. So your framework parses the incoming messages from JSON before passing it to the application’s callback function, and then serializes the results.

In Rust, the interface for the callback would look something like this (Value is a parsed JSON type from serde_json:

trait MessageHandler {
    fn handle_message(&self, input: Value) -> Value;
}

In a dynamically-typed language like Python, the callback function would look more like this:

def handle_message(self, input):

The code in the callback would then (hopefully) validate the JSON to make sure it meets the expect schema, and if it’s not, return some error in the reply message. In a programming language like Python (I make no promises that my Python is idiomatic or accurate; it’s meant as an example of a duck-typing language), it perhaps could be written like this:

if not self.is_valid_input(input):
    return {"error": "Invalid input", "input": input}

If the JSON is in a valid format, it would do some processing and return a non-error result.

The framework code, in order to do this, runs code that looks something like this (in pseudo-Python):

input = conn.recv_message()
input = json_parse(input)
output = handler.handle_message(input)
output = json_serialize(output)
conn.send_response(output)

And all of this will work just fine.

Except… what if the input isn’t valid JSON? And what if none of our test cases considered this possibility, but it nevertheless arises in production? What if we didn’t even write test cases?

Some Attempts to Solve#

Making sure we catch the error at all#

In Rust, we would already have a hint that there’s something wrong. JSON parsing in Rust is a function that can fail, and that is reflected in the type of the function to parse JSON, which looks something like this:

pub fn from_slice(v: &[u8]) -> Result<Value>

The Result means that this function can fail. We have to handle that failure in some way before we can get the resultant type. We can crash the whole program:

let input = from_slice(&input).expect("Invalid JSON");

NB: Reusing the name input like this with a different type is allowed in Rust; this declares a new variable that shadows the old one. This is idiomatic when the value is being transformed and we don’t need the old form anymore.

Or we can do what Python will likely do by default, and bubble the error up to the caller of the current function:

let input = from_slice(&input)?;

Or we can handle the error. And in this case, we should handle the error in some way, as we need to reply to the message whether it’s in JSON or not, and so we don’t want to skip over the code that does the reply.

Already, Rust’s typing discipline is helping us. In order to do what Python does by default, we need to at least opt in with a ?. Admittedly, the programmer may do that on autopilot, but it at least gives the programmer a hint that there might be an issue worth spending a second or two considering before moving onwards.

What to do with the error?#

But let’s assume that the programmer did, in fact, realize that these errors need to be handled. What should we do in case of an error?

One possibility is to handle it completely in the framework. If we know all inputs must be valid JSON, we can take this burden off of the application code:

try:
    output = json_parse(input)
except JsonError:
    output = {"error": "Invalid JSON"}

But what if we want to give the application-writer more flexibility? What if we envision a situation where the application-writer wants to accept either JSON or non-JSON data?

In a duck-typed programming language like Python, if the parsing fails, we can simply pass the original input to the handler. This is really easy to do.

try:
    input = json_parse(input)
except JsonError:
    pass

Now, the handler function just needs to ensure that the passed-in value is a dictionary in our validation:

def is_valid_input(input):
    if type(input) is not dict:
        return False
    if 'requiredField' not in input:
        return False
    return True

Of course, we might forget to do that, and if we do, we might now throw an exception when we run the not in test, which throws an exception if input is not in fact a dictionary. This would be bad, as not even all JSON parses to dictionaries, but it’s a mistake someone could make if they’re not thinking about error handling.

In Rust, we can’t pass the initial input directly to the handler, as it would be a different type. So if we try to do the direct equivalent to the Python, it gives us an error:

let input = match from_slice(&input) {
    Ok(parsed_value) => parsed_value, // This is the parsed value, type `Value`
    Err(_) => input, // This is the raw `Vec<u8>` data... TYPE MISMATCH!
}

We are then forced to brainstorm another solution, which might raise ideas we didn’t otherwise consider, and force us to backtrack in our design a little, which is actually a good thing because this solution, while simple in Python, has some flaws.

Here’s some solutions we might brainstorm:

  • Call a different callback in handler for unparsed data
    • Application specifies whether data should be parsed
    • Framework chooses which callback to call dynamically
  • Use an enum

That last one is interesting. If we do want to create a value that can contain either Value or Vec<u8>, we still can in Rust. We just have to create a new type that tells the compiler we want that:

enum IncomingMessage {
    Parsed(Value),
    Unparsed(Vec<u8>),
}

Then, before we can do any work on the wrapped Value, we have to say what happens if it’s actually a Vec<u8>:

let input = match input {
    Parsed(value) => value,
    Unparsed(_) => {
        // return an error JSON blob
    }
}

In fact, this even helps with the fact that not all parsed JSON is a dictionary, as serde_json::Value is itself an enum!

Further Problem#

But even if we do correctly validate that we have a dictionary, and we output an error in our message response if we don’t, I want to point back to our original pseudo-Python for what error to output:

if not self.is_valid_input(input):
    return {"error": "Invalid input", "input": input}

If input is JSON parsed into a dictionary, it will definitely serialize back into JSON, and this line makes sense. But now that input might not be parsed JSON, but instead might be in some sort of raw format, this dictionary might fail to serialize back into JSON.

Conclusion#

A lot of programming is converting data from one format to another and validating it. Strong static typing systems like Rust’s can help prevent mistakes before they happen, and force people to come up with more rigorous designs rather than shoe-horning different values into the same variable, which dynamic typing makes easy – too easy. I hope this example was relatable!