blog · git · desktop · images · contact & privacy · gopher
2022-04-21
You are reading version 2.0 of this blog post.
Readers shared this link on Hacker News and lobsters, which unexpectedly blew up and sparked many heated discussions. I’ve incorporated some of this feedback into this revised version.
(Some time later, there was also a discussion about this article on The Real Python Podcast.)
Over the course of several Python 3.x versions, “type hints” were introduced. You can now annotate functions:
def greeting(name: str) -> str:
return 'Hello ' + name
And variables:
foo: str = greeting('penguin')
To many people, myself included, static type hints are very useful and allow you to catch errors early on.
There are some surprises and limitations, at least there were for me. Let’s explore them.
The documentation says:
The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc.
So, this executes perfectly fine:
foo: int = 'hello'
print(foo)
By “execute”, I mean calling python foo.py
. As the documentation says,
to perform the actual type checks – that is, the checks of these static
type hints –, you must use some other program.
You sometimes see this:
# returns str
def greeting(name):
return 'Hello ' + name
Since the language’s syntax itself has no concept of static typing, people resort to adding a comment which states the return type. Similar to type hints, the runtime does not verify their correctness. People are aware of this, because they’re only comments.
Hungarian Notation, e.g. strName
, is another way of reminding the
reader about the type of a variable. Again, their correctness is not
verified, which is something that people know as well.
Type hints, on the other hand, are a bit special. They are part of the official language and “part of the code”. As a result, they create the impression that they are “binding” or “authoritative”. As we saw above, though, they are not verified by the runtime, either. Instead, they are only metadata that some other tool may or may not use. Whether type hints carry any meaning or are just random comment-like bytes in the file, depends on whether (and how) a type checker is being run or not.
So, you must run an external program. This “break” or “gap” between these two components can break people’s assumptions or expectations. It at least broke mine and still continues to do so. Checking static types is something that is usually done implicitly when you run/compile a program, but not so in Python.
It gets a little more confusing, because Python does indeed check types during runtime:
>>> 1 + '1'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
This is just completely decoupled from the type hints that you manually put in your code. I think that’s a bit unfortunate and really not what I expected. It’s not just a break of expectations, but it also has consequences, as we’ll see later on.
Basically, I would have hoped the Python runtime does something along
the lines of isinstance()
behind the scenes, whenever I assign a new
value to a variable that has a static type hint. It very well knows the
true types at runtime, so please go check if that matches what I wrote
in the source code.
Alright, so you have to bite the bullet and run an external tool. I’ll be using mypy here, but there are others.
One option is to add a mypy step to your CI pipeline.
Now, I have made the experience that this might not be as simple as it may sound. (Your experience may differ, of course.) Running mypy is an optional step. When this silently fails, it will probably go unnoticed. This is different from traditional compiled languages: When the compilation step (which implicitly checks static types) fails, then you don’t get an executable program at all. You will notice that.
If the Python runtime enforced type hints, then this could somewhat mitigate this problem: You probably have a test suite, so your code is being run and that might then expose errors in type hints, too. (Sure, running a test suite also suffers from being optional.)
So why would a type checker not run? Or maybe not correctly?
Overall, you get additional maintenance burden and all of this is a somewhat fragile process, since checking static type hints is not tightly integrated into “getting the program to run”.
My personal takeaway from this is to not trust Python’s type hints, just because they’re in the code. Only when I see a type checker really do its job and only when I have inspected which flags that checker uses, do I start paying attention to type hints. This is a very different experience compared to other languages and contributes to my disappointment.
But it also depends a lot on your tooling: If you always use heavy machinery, which immediately runs a type checker, before you’ve even read a single letter with your eyes, then this probably isn’t as much of a problem for you. I like to go lightweight, though. In other words, if using such heavy machinery was mandatory to write meaningful code, then this would be another point that I’m not too happy with.
Any
typePython is a dynamically typed language. By definition, you don’t know the real types of variables until runtime. It is not always possible to statically annotate each variable in the source code.
So, naturally, there now is an Any
type. The following program passes
mypy validation:
from random import randint
from typing import Any
def foo() -> Any:
if randint(0, 1) == 0:
return 42
else:
return 'foo,bar,baz'
bar = foo()
print(bar.split(','))
Of course, in 50% of the cases you get an exception, which is fine and matches my expectations.
Any
goes both ways, though, which was pretty surprising to me:
A special kind of type is Any. A static type checker will treat every type as being compatible with Any and Any as being compatible with every type.
So, this is valid code and passes mypy:
from typing import Any
def foo() -> int:
bar: Any = 'hello'
return bar
result = foo()
print(result)
foo()
does not return an integer, though.
Mypy has an option to turn this into an error:
$ mypy --warn-return-any foo.py
foo.py:9: error: Returning Any from function declared to return "int"
Found 1 error in 1 file (checked 1 source file)
I guess they cannot turn this on by default, because, well, the Python docs say it’s valid. And, sure, Python is not a brand-new language, so adoption of type hints takes lots of time, which is why they are probably not willing to enforce each and every check right from the start. (It’s been a few years, but it’s still young.)
Mypy knows a --strict
option, which turns on “all optional error
checking flags”. It’s probably best to turn this on by default and then
add only those inverse flags that you need, like mypy --strict
--no-warn-return-any foo.py
.
Any
can sneak in through libraries as well. What you then have to do
is configure your type checker to ignore certain things just for that
library. This means you’ll have to keep an eye on said library over
time, so you can find out if/when you can enable these checks again.
I’m not very happy that you have to twiddle with those flags. It contributes to the maintenance burden and fragility. But it is what it is and probably hard to avoid.
In general, steer clear of Any
in your own code. It should be a last
resort. Its usage is surprisingly frequent, though, which is why I
tripped over it many times. People have commented that you can do nasty
things like this in many languages, sure, I was just surprised to see it
happen so often here with Any
. Could be just bad luck.
The docs mention a different approach, which is to use object
instead:
from random import randint
def foo() -> object:
if randint(0, 1) == 0:
return 42
else:
return 'foo,bar,baz'
bar = foo()
if isinstance(bar, str):
print(bar.split(','))
else:
print(f'bar is something unexpected: {type(bar)}')
# ... handle this situation gracefully ...
It still allows you to create “dynamic” objects during runtime and to pass them around, but as soon as you want to do something with them, you have to narrow it down to a concrete type – and this is clear from the code.
(In that particular example, Union[int, str]
could be a better
choice.)
Note that Any
is not supposed to be an alternative to object
. They
are basically the opposite of each other. The docs put it this way:
Use
object
to indicate that a value could be any type in a typesafe manner. UseAny
to indicate that a value is dynamically typed.
In other words, Any
throws you back to “untyped” Python while still
making your type checker happy. (The wording “throws you back” implies
that “untyped” Python, i.e. dynamically typed Python without static type
hints, is a bad thing. In the context of this blog post, it kind of is.
In general, though, it’s simply your choice.)
Any
is also a way to introduce type hints to your project step by step
– and then you risk it staying there. It’s tough.
This is just a minor issue, but still a bit baffling.
Example:
foo: int = 123
bar: float = foo
if isinstance(foo, int):
print('foo is an int')
if isinstance(foo, float):
print('foo is a float')
if isinstance(bar, int):
print('bar is an int')
if isinstance(bar, float):
print('bar is a float')
Passes validation:
$ mypy --strict numeric.py
Success: no issues found in 1 source file
Unexpected result:
$ python numeric.py
foo is an int
bar is an int
How can I “declare” bar
as float
, but then it accepts an int
and
actually is an int
at runtime (so it’s not like it’s being converted
automatically – of course not, the runtime does not care about type
hints)?
The reason is duck type compatibility.
This is probably not a big deal in Python, though. When you divide two
integers, 1 / 3
, the result is a float
and not accidentally an
int
, like in some other languages. And luckily this is limited to just
a few built-in types.
And yet … It says bar: float
but it’s an int
. They could have just
called it number
, if it’s ambiguous anyway.
Most Python projects out there pre-date type hints, so they don’t contain any type hints at all. This is unavoidable. If do you want to make use of type hints when using such libraries, well, you have to add the hints. typeshed contains hints for a bunch of popular projects.
This means: The library itself and its type hints are out of sync. When a type checker does not report any errors for your code, what does that mean? Do you actually call that library correctly?
This will hopefully get better over time. Type hints are an optional thing, though, so there is a chance that we will always have to deal with this.
So we were making a client for a REST API. Traditionally, we would have built dicts and then POSTed them:
payload = {
'cars': [
{
'name': 'toy yoda',
'wheels': 4,
},
],
'salad': 'potato',
'version': 8,
}
Code like this can get really messy really fast. Python 3.7 introduced dataclasses. Together with type hints, it might look like this:
from dataclasses import dataclass
from typing import List, Literal
@dataclass
class Car:
name: str
wheels: int
@dataclass
class APIRequest:
cars: List[Car]
salad: Literal['potato', 'italian']
version: Literal[8]
payload = APIRequest(
cars=[
Car(
name='toy yoda',
wheels=4,
),
],
salad='potato',
version=8,
)
I’d argue that this is better code. Dataclasses and type hints allow the reader to know how an API request is supposed to be composed.
If you now turned wheels=4
into wheels='4'
, mypy would report an
error.
But how much of your code handles static data like this? Isn’t it much
more likely that this '4'
is actually a variable? The big question
then becomes where this variable comes from and whether it’s covered by
(correct) type hints. If it isn’t (or if it’s Any
), then your build is
green, but your code might be wrong and will fail somewhere down the
road (just as if you didn’t have type hints in the first place).
It would have been really nice if dataclasses automatically honored
their type hints and raised errors on mismatches. One way to do this
would be to have the runtime enforce type hints – but as that’s not the
case right now, dataclasses could maybe perform appropriate
isinstance()
calls in their constructors.
pydantic was brought up as an
alternative to plain dataclasses. It’s worth a closer look. I’m a bit
worried when I look at the example on their page, though, because they
“declare” friends: List[int]
which then happily accepts 'friends':
[1, 2, '3']
. Maybe this can be tweaked or maybe it is intentional, I
don’t know yet.
I’d still argue that it’s better to use dataclasses (or pydantic) than to compose large dicts with lots of different types, but this is probably a personal preference of mine. Many Python programmers praise the language for not having to use classes like this.
Let’s have a look at another dict. Consider this:
foo = {
'hello': 'world',
'bar': ['baz'],
}
foo['bar'].append('potato')
print(foo)
It executes fine:
$ python bar.py
{'hello': 'world', 'bar': ['baz', 'potato']}
Mypy is not happy with it:
$ mypy --strict bar.py
bar.py:6: error: "Sequence[str]" has no attribute "append"
Found 1 error in 1 file (checked 1 source file)
This is to be expected. The code doesn’t contain type hints, so mypy is forced to guess, and sometimes this goes wrong.
So, how would we annotate this correctly? It could look like this:
from typing import Dict, List, Union
foo: Dict[str, Union[str, List[str]]] = {
'hello': 'world',
'bar': ['baz'],
}
foo['bar'].append('potato')
print(foo)
The annotations are “correct” now, mypy still complains, though:
$ mypy --strict bar.py
bar.py:9: error: Item "str" of "Union[str, List[str]]" has no attribute "append"
Found 1 error in 1 file (checked 1 source file)
We couldn’t properly express that only bar
is a list and hello
is a
string. We now first have to resolve the type ambiguity of this Union
,
so that we’re making sure that what we’re dealing with really is a list:
if isinstance(foo['bar'], list):
foo['bar'].append('potato')
I think this is great. This is the first step towards better code: It
tells you that, whoops, you’re dealing with something that might not be
what you think it is. The same happens when you use Optional
and don’t
check if it’s None
. And all of this can happen long before you
actually run this thing.
When you reflect on this for a moment, you will notice that Dict[str,
Union[str, List[str]]]
is kind of a crazy type. These things can get
really massive as you expand your dict with other things. I have seen
Dict[str, Union[Dict[str, Union[int, str, bool]], List[int]]]
in the
wild, which is very hard to understand and provides little value.
In my opinion, when you have to write types like this, it should be a
sign to you that you should rethink your data structures. Wouldn’t it be
better if you moved your data to proper classes which could then have
easier to understand type hints? Wouldn’t it be easier to understand how
your structure is composed? Just writing such a dict in one go might be
fine, but using it later on or composing it with lots of if
and
update()
can confusing quickly.
I think that type hints are doing a great job here: They expose overly complicated data structures. I call these things “chaotic dicts”.
Now, in discussions with other people, I have found that this is mostly personal preference or even a cultural clash. As mentioned above, people often like using “chaotic dicts” very much, because they value the flexibility that this offers. Moving to classes or “structs” is frowned upon.
Thus, what happens in real life is often this:
from typing import Dict
foo: Dict = {
'hello': 'world',
'bar': ['baz'],
}
foo['bar'].append('potato')
print(foo)
And now the type checker is happy – but that’s only because foo
now
has the type Dict[Any, Any]
, which, as we’ve seen above, can have bad
consequences.
My criticism regarding type hints here is that it’s too easy to use
Any
. I have no idea how, but I’d love if it were more involved to
declare something as Any
. Or at least don’t call it Any
, because
that sounds too innocent and legitimate. :-) Maybe UnsafeAny
or even
CodeSmellAny
or … something, that nudges the programmer into using it
as little as possible. (I understand that Any
is legitimate as a
concept, but it really does undermine the point of type hints when you
intentionally introduce it to new code.)
Exceptions are out of scope of type hints: You cannot annotate a
function and say, “this might throw a ValueError
”.
This is a bit unfortunate in my opinion. In Python, exceptions can be very surprising, because you never know when they are being thrown. In, say, Java, exceptions are part of a function’s signature, and this has helped me a lot in the past.
It has been commented that exceptions are intentionally not covered. The commenter couldn’t provide a source (it’s supposed to be in a talk by Guido himself) and I couldn’t find it either. If I do, I’ll add it.
That’s the big question.
Honestly, it has been my experience in the past years, that Python’s type hints do more harm than good, due to their deceptive and misleading aspects and overall fragility. In real life (or at least in my real life at work), they have been pretty disappointing. As a result, at the moment, I am very skeptical, I hardly trust that system, and I’m not overly motivated to use them in my projects.
The previous version of this blog post said: “No, they are not worth it.” The reason being that the net sum was negative in my experience (“more harm than good”). It also had a section that questioned whether you don’t want to simply use one of the traditional compiled languages instead.
On the other hand (and this is where we diverge a lot from the original version):
Are Python’s type hints completely broken and thus utterly useless? I don’t think so. And they can be beneficial. So, overall, do they more harm than good, then? This probably very much depends on your particular projects – there is no universal answer.
Give them a try. Decide for yourself.
One reader mentioned Meta’s Cinder, of which “Static Python” is a part.
I quote:
It does type checking at bytecode compilation time (so byte-compiling your code will fail if the type annotations are wrong) and at boundaries between code that you’ve opted in to this compilation and code that hasn’t, it automatically inserts runtime checks, so the type annotations in Static Python compiled code can always be trusted, even if it has to interact with untyped code.
This is much more what I originally expected.
I haven’t checked this out in detail yet and Cinder today is not meant to be used as a general replacement for CPython. There is a chance that this might be available as some sort of pip package at some point in the future, the reader said.
Another reader said that there was a discussion about making CPython respect type hints at runtime after all – we couldn’t dig up the source for this, though. (– edit: Link to said discussion – it’s open ended, but has lots of “no” in it.)
It means that there is still work going on in this area. Things might look very different in a few years.