Mypy Tips and Tricks
Here’s a collection of various tips and tricks for mypy, a static type checker for Python. Some are more opinionated than others, but they all will help you leverage mypy to its full potential.
Use a type checker
First off, if you’re using type hints in your code, I highly recommend you type check them with a tool like mypy. The only thing worse than unannotated code is incorrectly annotated code.
If you’re using mypy, I like the following mypy.ini
for my config:
[mypy]
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
follow_imports = normal
ignore_missing_imports = true
no_implicit_reexport = true
show_error_codes = true
show_error_context = true
strict_equality = true
strict_optional = true
warn_redundant_casts = true
warn_return_any = true
warn_unused_ignores = true
The rest of this post touches on most of these flags and the idea behind them.
Enable None checking
Help mypy catch the billion-dollar mistake and enable None
checking with the --strict-optional
flag. Luckily, this is the default in mypy starting from version 0.600
.
With this flag, mypy would disallow the following code:
def foo(x: int) -> None:
...
foo(None) # None is not of type 'int`
I also recommend setting the --no-implicit-optional
flag, which disallows code like the following:
# wouldn’t pass with the flag
def foo(x: str = None) -> str:
...
# but would with an explicit Optional annotation
def foo(x: Optional[str] = None) -> str:
...
In general, being explicit is better than being implicit. This is especially true as the codebase grows older and the number of developers touching it increases—i.e., programming in the large. The time it would take to fix any future bugs caused by an implicit optional would dwarf the few extra seconds it takes to be explicit when first writing the code.
Remember, code is read more often than it is written, and it is run more often than it is read.
Beware of “Any” types, especially silent ones
Mypy will pass any type checks that contain an Any
type. Any
is a helpful escape hatch when retrofitting type hints onto an existing codebase. Unfortunately, it can also jeopardize the type safety of your program if it appears in places you aren’t aware of.
Silent Any
types can appear when using types from your dependencies if you have not configured mypy to follow imports. For example, if you import a method from a third-party dependency, then mypy will treat values returned from that method as Any
:
from dep import foo # dep is a third-party dependency
def bar(s: str) -> int:
...
x = True
y = foo(x)
bar(y) # this passes mypy regardless of the type of y
Note that even if foo
has type hints, mypy will still always pass the above line if it is not configured to follow imports. Luckily, --follow-imports=normal
is the default for mypy, but this option is often turned off when adding mypy to an existing codebase.
Unfortunately, this behavior is not always clear, and mypy can lull you into a false sense of confidence when it passes your code while in reality, it’s considering everything as Any
. Speaking of which …
Make sure your library has a py.typed file
PEP-561 specifies that if you want your package to export its type hints, then you must include a py.typed
marker file in your package installation. The file is just an empty file. If you do not include it, then your package’s users will not be able to leverage your type hints in their mypy runs. To them, it will be as if your package has no type hints at all.
This is a small but crucial detail that is all too easy to miss while you’re learning about Python’s typing
module. As the PEP states, you’ll also need to declare this py.typed
file as part of your package’s installation. For example, you can add it to the package_data
section of your setup.py
file.
To confirm this behavior, we can see how mypy infers the type of an imported constant with reveal_type
:
from dep import CONSTANT # CONSTANT = 1
reveal_type(CONSTANT)
$ mypy foo.py
foo.py:1: error: Skipping analyzing 'dep': found module but no type hints or library stubs
foo.py:1: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports
foo.py:3: note: Revealed type is 'Any'
Mypy thinks it’s Any
, even though the actual value is the integer 1
. But after we add an exported py.typed
file to our dep
dependency:
mypy foo.py
foo.py:3: note: Revealed type is 'builtins.int'
Mypy correctly infers the type as an int
.
Consider using object instead of Any in some cases
Sometimes, you’re deserializing a big JSON blob or dealing with a dictionary with multiple different, complex value types. In cases like these, it may be tempting to reach for Any
as the type hint. The object
type hint is similar to TypeScript’s unknown
type.
Consider using object
as a typesafe alternative to declaring that a value could be anything. Mypy won’t allow you to do most things with object
; you’ll have to narrow the type with isinstance
checks or similar:
def foo(x: object) -> int:
return len(x) # mypy rejects this
def foo(x: object) -> int:
if isinstance(x, str):
return len(x) # passes mypy as we've confirmed x: str
elif isinstance(x, int):
return x # passes mypy as we've confirmed x: int
raise TypeError()
This is the key distinction between Any
and object
: Use Any
to opt-out of static type checking, and use object
to declare that the value can be of any type.
Of course, if you’re declaring that a value can be of any type, then there’s not a lot of useful things you can do with that value until you introspect its type. As this blog post eloquently describes it, “you can’t process what you don’t know.”
While there is no distinction between Any
and object
at runtime, using object
in your code will let mypy force you to prove that you can safely use and manipulate your value before you do. Any
will not.
Tell mypy to show error context
The --show-error-context
flag directs mypy to give a couple of lines of context for every error.
Without the flag:
$ mypy bar.py
bar.py:2: error: Argument 1 to "len" has incompatible type "object"; expected "Sized"
Found 1 error in 1 file (checked 1 source file)
With the flag:
$ mypy bar.py --show-error-context
bar.py: note: In function "foo":
bar.py:2: error: Argument 1 to "len" has incompatible type "object"; expected "Sized"
Found 1 error in 1 file (checked 1 source file)
Not a big deal (and the added verbosity could be unwanted in CI), but it’s helpful when you’re not as familiar with the code.
Enable “drift” checks
The --warn-redundant-casts
and --warn-unused-ignores
flags tell mypy to warn you if there’s an unneeded cast or unused type: ignore
comment.
Both of these usually happen over time as your code starts to drift. That is, when you first write the code, you might need the cast(str, x)
function to direct mypy a certain way. But later on, if you update another section of the code, then you may no longer need it.
It’s not always obvious when updates to one section of the codebase affect mypy’s understanding of another section. These flags help prune your code over time.
Mypy continues to get smarter as new versions are released, and previous type: ignore
comments due to mypy bugs or deficiencies can become stale over time. It is especially important to remove these when they’re stale, as they can prevent mypy from notifying you of different but valid type errors on the same line of code.
Disallow untyped definitions and method calls
If you’re going to adopt mypy on your codebase, it helps to go all in and annotate all your methods. The --disallow-untyped-calls
and --disallow-untyped-defs
flags tell mypy to enforce this. Adding type hints to an existing codebase is hard. Luckily, there are some great tools to ease the transition, like typing_copilot.
Dealing with untyped methods causes mypy to treat types as Any
, passing all subsequent checks involving them. It is better to explicitly annotate your methods with Any
types than to leave them unannotated, as being explicit is better than being implicit. Also, this reduces the barrier to refine your type hints in the future.
These flags also prevent any new unannotated code from being added to the codebase, helping ensure your codebase doesn’t regress in this respect.
Disallow untyped decorators
Wrapping your typed method with an untyped decorator will cause your method to be untyped. Mypy understands the type hints in a regular typed function:
def foo() -> int:
return 2
reveal_type(foo)
# Revealed type is ’def () -> builtins.int'
If we decorate this method with an untyped decorator, mypy now infers the method as untyped:
def decorate(func):
def inner():
return func()
return inner
@decorate
def foo() -> int:
return 2
reveal_type(foo)
# Revealed type is 'Any'
We took care to add type hints to our functions. It would be a shame if we miss out on some benefits because we happen to decorate with untyped decorators.
The --disallow-untyped-decorators
flag directs mypy to warn whenever it encounters a typed method wrapped by an untyped decorator. This is a great way to prevent the introduction of some silent Any
types into your code.
No implicit re-export
Python allows you to import foo
from file A even if file A doesn’t declare foo
, provided that file A imports foo
from somewhere else. That is, the following situation is valid:
# File A
from file_b import foo
...
# File B
def foo():
...
# File C
from file_a import foo # Note that it's file_a, not file_b
Even though this works, this indirection increases the likelihood of problems in the future. If we refactor file A so that it no longer imports foo
, then file C will also stop working.
Luckily, the --no-implicit-reexport
flag directs mypy to warn us whenever it detects this situation.
With this flag, we can still export the imported foo
from file A with either of the following:
# File A
from file_b import foo as foo
# File A
from file_b import foo
__all__ = ["foo"]
You’ll receive benefits from this flag even if you rely on exporting imported values as it forces you to make them explicit.
Disallow implicit type parameters in generics
By default, mypy allows you to use List
and Dict
as type hints without specifying type parameters. Mypy uses Any
for all type parameters you don’t specify, so List
is equivalent to List[Any]
and Dict
is equivalent to Dict[Any, Any]
. Starting with Python 3.9, you can specify type parameters with the built-in collections, like list[str]
. See PEP-585 for more details.
The --disallow-any-generics
flag directs mypy to reject any generic type hints without explicit type parameters.
You start to develop a healthy distrust of Any
as you work more with Python type hints. Any
has legitimate uses, but you’ll correctly double-check the context when you see one. This increased caution is good since mypy will gloss over Any
types.
Enabling this flag continues our trend of preferring explicitness. Generics without type parameters are a great place for Any
types to hide. Dict[Any, Any]
will catch your eye—while Dict
may not.
Enable a few more warnings
The --warn-return-any
flag tells mypy to produce a warning if you’re returning Any
from a method declared to return another type.
The --warn-unreachable
flag tells mypy to produce a warning if it detects code that will never execute.
The --strict-equality
flag tells mypy to flag any comparisons between non-overlapping types. These always evaluate to false at runtime.
All the conditions that trigger these warnings are often the result of buggy code or silent Any
types. These warnings, along with the other tips discussed in this post, will help you better leverage mypy to create more robust and maintainable software.