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.