Pitfalls of Type Hinting in PythonJake Robertson - February 11, 2019
Type hinting was introduced in Python 3.5 and allows you to describe object types in your code. A basic example looks like this:
def foo(text: str) -> bool: # ... var: bool = foo("Typing is fun!")
If we take this at face-value, we should be reasonably confident that
foo received a string and
var will now be a boolean.
But can we guarantee it? No: the interpreter neither checks the type of the
parameter or return value, nor tries to cast them to the annotated type.
The following situation is entirely possible:
>>> var: bool = foo(5) >>> type(var) <class 'int'>
This article describes some of the pitfalls caused by undue assumptions about type hinting, and why proper documentation is sufficient for making claims about types.
Descriptive vs. prescriptive documentation
There is more to describing a function parameter than its type. If your
function modifies one of your parameters, that behavior should be documented. If
string parameter needs to be in a certain format, you should
describe the requirement and maybe give an example. And, of course, if
your function requires the parameter to be an
int, the expected
type should be documented.
These requirements, usually included in a docstring, are the contract for your function. It may not be strictly enforced, but if the contract is broken then the function behavior is no longer defined -- any subsequent bugs are "not your fault." Therefore, your contract becomes very important: the requirements it lays are not descriptive, they are prescriptive.
By itself, parameter documentation can consist of three parts:
- Necessarily, the "what": I need a variable of <type> which will be called <name> in my scope;
- Optionally, the "why": I need this variable to accomplish <task>;
- Optionally, the "how": I will accomplish <task> by doing <method> with this variable
In many cases, it may be sufficient to only include the "what." If I am
writing a function called
median which takes an array of integers,
I probably do not need to say much more for a reader to understand the
function, unless I am doing something particularly complex. A type annotation
alone offers that. However, defining more complex behavior (such as
restrictions on an integer's value, calling another function before this one,
etc.) requires more writing which must also be adhered to.
This is why it is important that documentation is prescriptive: your contract says everything that a client needs to know about how to use your code. If it imposes a requirement, the client should follow it; if a parameter is used in an unusual or not-generally-expected way, the client should be informed. If a client is able to successfully use the function while breaking its contract, something has gone wrong. The contract needs to be updated to reflect the additional things it allows, or the function should explicitly fail with the bad input. Contracts only produce good code when they are strict.
Here's an analogy: I go to a grocery store once a week. It generally has good security, with multiple security cameras and with employees watching for thieves. After taking my items, I go to a self-checkout. Unbeknownst to me, the machine mistakely, and routinely, fails to record the barcode of a specific candy bar and marks it as $0.00. It is a relatively small portion of my bill so I simply pay the total without noticing the difference. This happens with the same candy bar every week.
Obviously, something in this scenario has gone wrong. I am not necessarily a malicious actor -- I could be head of security for the store, just doing my shopping that day. However, since no one bothered to check that potential exploit (or failed to adequately do so), no one noticed that I was effectively stealing. The store still operates like normal - most people go through and pay the correct total - oblivious to my misuse of the system. Security in the real-world can be tough, and often involves substantial cost-benefit analyses, but it does not need to be so difficult to enforce contracts in code. In the above example, I broke the machine's contract by giving it an input that it did not understand. However, since it did not read the barcode input correctly and did not subsequently fail in an explicit way, I mistakely exploited its undocumented behavior.
Typing is law
Despite Python's frequent insistence on safely handling type mismatches, it is extraordinarily rare that a developer would be "okay" with a variable being a different type than what they expect it to be. When you declare a variable to be a certain type, you are also making a contract.
Type annotations, in a local scope, are not documentation. Python makes it easy to change type without penalty. We can redeclare an annotated variable without warning (perhaps unintentionally) and proceed to pass it to functions and unknowingly break their contracts. Worse, those functions may, despite asking for a specific type, be lenient with your argument. Obviously, this would not be an issue if one never makes type mistakes, but that is not a common assumption. These problems can be difficult or impossible to test for, and often requires a static type checker (see below).
Type hints are not strict. If we cannot guarantee a variable is of the type its annotation claims it to be, we have not improved the quality of our code. It still requires implicit trust -- trust that your contracts are followed. You could say it helps others using your code to understand your function parameters, but that already requires the caller to have sufficient understanding of their own variables. In essence, there is no practical difference between type hinting and proper documentation.
It would probably not be surprising at this point to say that I prefer strict code interpretation, and for programming mistakes to fail early and harshly. I recognize the realities of business, but this belief does not conflict with pragmatism: if your code would be otherwise unmaintainable several years in the future when your work still depends on it, it is worthwhile to spend extra development time to avoid that situation.
Assertions as a contract debugging tool
Assertions are not just for testing. They can also be a useful way of safely "sanity checking" your contracts, or really any defined behavior. It is rare that it would be more user-friendly to fail asserts in response to user behavior, but they can be effective "breakpoints" that get triggered if you are making use of a function and accidently violate its contract. This could primarily be an effective use case when checking types, but it can also be used for more complicated checking e.g. ensuring a data type has a required field.
This further enforces the claim that contract violations should be punished. You could write a bunch of tests for passing in other types, but practically speaking you can never be sure a function always breaks when passing in bad input (which should happen) -- especially since operator overloading is quite common in Python. One of the worst things that can happen for a codebase is for a function to be commonly used in a way that was not intended, causing maintenance issues in the future.
Static typing vs. static type checkers
After the advent of declarative typing, several static code analyzers/type checkers were developed to enfore type declarations. The most notable of which currently is probably mypy, a "compile-time" type checker. This is a great endeavor, but it also brings its own pitfalls. The main concern is that it is not perfect, but it needs to be. If you use static type checkers and type annotations as an "implementation" of static typing, you are likely to ease yourself into ignoring type-based bugs that could have been avoided with a truly staticly typed language. Again, creating a maintainable codebase with Python requires adapting to its un-strict nature, including type-based unit testing and perhaps "sanity check" assertions.
What about type comments?
Type comments are a standardized backwards-compatible way of declaring
object types. You can write a variable as you would normally, then at the end
of the line write a comment with
type: followed by the type
(written identically to a type annotation). It looks like this:
from typing import Union, Iterable def foo(name, # type: str age, # type: int response # type: Union[Iterable[bytes], int] ): # type: bool # ...
The main benefit of this method is that it allows you to compile for Python versions pre-type-hints. Even though it is extremely unlikely you need to be cross-compatible with Python 2, there are still many packages which support Python 3.2-3.4.
Ultimately, the only use for type comments is if backwards compatibility is absolutely essential; it rarely is. Perhaps to some extent their format helps to denote the fact that typing is not prescriptive, but realistically they offer little benefit and only make definitions messier. If you want to make annotations like this, just use a docstring -- most static analyzers will pick up on either, anyway.
Type hints are not bad, they are simply insufficient. Programming mistakes should be punished quickly and harshly. Relying too much on type annotations allows programmers to make mistakes which are difficult to trace. Detailed, written documentation can be enforced in punishing ways. If I deliberately or knowingly break a contract, I should be scared about what will happen to my data. Relying on type annotations does not ensure that your code will not be misused in the future (whether by your coworker or by yourself). This can lead to messy maintainabilitiy issues and can ultimately punish those who make undue assumptions about type hinting.