The problem with using exceptions for validation
Applications frequently use exceptions for handling validation of data from users. While harmless at first, they come with strong limitations and suppositions about how to perform validation.
1. Activity
Imagine writing a JSON HTTPS API. When receiving a request, you want to validate it.
In Python, the function you have to write is as follows:
def validate(data: Dict[str, Any]) -> HttpResponse:
pass
validate
takes a data
of type Dict
(the parsed JSON object), and returns
an object of type HttpResponse
.
Let’s now imagine we need the following checks:
-
the key
username
must be present, and its value must be non-null, and a non-empty string; -
the key
age
must be present, and its value must be non-null, and a positive integer; -
the key
gender
might be present. If present, it must be a 1-letter long string.
2. Validation with exceptions
We create 1 function per key validation, and we raise an exception upon failure. They are then called from validate
.
validate_username
def validate_username(data: Dict[str, Any]) -> str:
username = data.get('username', None)
if username is None:
raise Exception("'username' must be provided")
if not isinstance(username, str):
raise Exception("'username' must be a string")
if len(username) == 0:
raise Exception("'username' must be non empty")
return username
validate_age
def validate_age(data: Dict[str, Any]) -> int:
age = data.get('age', None)
if age is None:
raise Exception("'age' must be provided")
if not isinstance(age, int):
raise Exception("'age' must be an integer")
if age < 0:
raise Exception("'age' must be positive")
return age
validate_gender
def validate_gender(data: Dict[str, Any]) -> Optional[str]:
gender = data.get('gender', None)
if gender is None:
return None
if not isinstance(gender, str):
raise Exception("'gender' must be a string")
if len(gender) != 1:
raise Exception("'gender' must be a 1-letter string")
return gender
With those 3 functions defined, calling them in validate
is a matter of wrapping them in a try/except block:
validate
def validate(data: Dict[str, Any]) -> 'HttpResponse':
try:
username = validate_username(data)
age = validate_age(data)
gender = validate_gender(data)
except Exception as e:
return HttpResponse(status=400, body=e)
3. Implicit assumptions and User Experience
The previous code works as you would expect. Yet, what happens when the user sends the following data:
{
"username": "",
"age": "73"
}
The user would only get 1 error saying that username
must be non-empty: validate_age
would never be executed, leaving the user to believe there is only 1 error.
The user has to resubmit the data after fixing username
. A new error will be sent
back about age
not being an integer.
Leading to a third submission by the user.
This shows that exceptions do short-circuit the rest of the code during validation, preventing from showing all errors altogether, instead of one after the other.
In the Activity, no requirement impose that errors must be returned one by one instead of all at once.
4. Multiple errors with exceptions
Fixing this with exceptions is not impossible, but turns exceptions into something you must store and manage.
def validate(data: Dict[str, Any]) -> 'HttpResponse':
exceptions = []
try:
username = validate_username(data)
except Exception as e:
exceptions.append(e)
try:
age = validate_age(data)
except Exception as e:
exceptions.append(e)
try:
gender = validate_gender(data)
except Exception as e:
exceptions.append(e)
if len(exceptions) > 0:
return HttpResponse(status=400, body='\n'.join(exceptions))
5. Conclusion
When used for input validation, exceptions force a sequential handling of error messages. This is their inherent short-circuiting nature.
The example “fix” shows some duplicate code hinting at a better solution. Think about it or see you in another article for more.