+-----------------------------------------------+ |The views and thoughts below are purely my own.| +-----------------------------------------------+
This post was written 2025-09-05
Python Type Hints: Invariant Types
For many Python engineers, type hints are a useful everyday tool. They act as inline documentation, enable analysis and linting, and can even drive runtime behavior.
But many practitioners have a limited understanding of a specific, often irksome, topic in the type system: Type Variance. When variance comes up, it’s usually because
- you have a deadline to meet
- your team requires your code to pass all checks in CI
- your code is meticulously tested, and passes your testsuite
- the type checker box in CI is red
That is, type variance is a topic likely to come up when engineers are at their least receptive to learning. “The code is good, just let me ship it.”
A better understanding of type variance can help avoid these situations, and make it feel more like you are working with the type checker, rather than against it. This post will focus on one kind of types, Invariant Generics.
Type Hinting in the Wild: Invariance Appears!
Type hints are usually first explained in terms of simplistic examples which “make sense”, like
def my_sum(data: list[int]) -> int:
if not data:
return None # Error! This isn't an int!
return sum(data)
A type checker can catch that None
doesn’t match the -> int
(return type), and warn you.
Then you can fix it, e.g.:
def my_sum(data: list[int]) -> int:
if not data:
return 0
return sum(data)
But when you start exploring on your own, you’ll quickly run into issues which don’t match these happy cases. For example, consider this function:
def sum_the_ints(data: list[int | str]) -> int:
integers = [x for x in data if isinstance(x, int)]
if not integers:
return 0
return sum(integers)
This seems… pretty reasonable, right?
sum_the_ints
takes a mixed list of integers and strings, and gets you a sum of
just the integer parts.
Does it type-check?
$ mypy script.py
Success: no issues found in 1 source file
Great!
So now let’s use it:
def make_some_ints() -> list[int]:
return list(range(10))
print(sum_the_ints(make_some_ints()))
Looks great, we should get an integer printed!
$ python script.py
45
Cool. Let’s check it:
$ mypy script.py
script.py:12: error: Argument 1 to "sum_the_ints" has incompatible type "list[int]"; expected "list[int | str]" [arg-type]
script.py:12: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
script.py:12: note: Consider using "Sequence" instead, which is covariant
Found 1 error in 1 file (checked 1 source file)
Zoinks! That’s no good!
list is Invariant
The mypy
error links you to a useful page in its docs about variance, and it
suggests using Sequence
, which we’ll get back to.
All-in-all, a pretty good error!
The pages on variance are clear and detailed, but because of the level of detail, it’s easy to get lost. And it can be hard to connect what you’re reading back to the error you got.
The key bit is note: "list" is invariant
. What does that mean?
When you declare a list
type, you can declare the type of the list
’s
contents, i.e. list[int]
means “this list contains integers” and list[int | str]
means “this list contains a mix of integers and strings”.
Note that list[int | str]
is a different type from list[int] | list[str]
!
One describes a list
which may be heterogeneous, like [2, "apples", 3, "oranges"]
,
and the other describes a list
(still singular) which is homogeneous, but could be
either filled only with integers or filled only with strings, [2, 3]
or ["apples", "oranges"]
.
Any type which has some “slot” or “slots” which can be filled in, like list
, is a Generic
.
All of the basic container types – list
, dict
, set
, tuple
– are generics because we can
describe the type of their contents.
Type variance describes how a generic type interacts with its inputs, the “type arguments”, within
the confines of the type system.
Invariance, in particular, is the rule which says “the type arguments must match exactly”.
If you look back at the erroring example above, you’ll see that the types do not match –
one is list[int]
and the other is list[int | str]
!
So, to summarize,
list
is invariant- invariance means that “type arguments must be an exact match”
list[int]
is not exactlylist[int | str]
That explains it! … Sort of. But it raises more questions. Why is list
invariant?
Mutable Containers are Usually Invariant
The key feature of list
(at runtime) which makes us describe it as invariant (at typing-time)
is that we can mutate its contents freely with methods like append()
and
pop()
and indexed assignment (__setitem__
).
If you have a list of integers, you can add integers to it:
data: list[int] = [1, 2, 3]
data.append(4)
And functions which take lists
can do this too:
def incr_append(data: list[int]) -> None:
if not data:
data.append(0)
else:
data.append(data[-1] + 1)
incr_append()
reads and writes integers from and to its input.
It assumes that data which comes out of the list is always an integer,
and that data going in can be an integer.
So, can we use it on a mixed list?
items: list[int | str] = [1, 2]
incr_append(items)
print(items)
Well, at runtime no problem:
$ python script.py
[1, 2, 3]
But if we try to type check it, this errors.
$ mypy script.py
script.py:9: error: Argument 1 to "incr_append" has incompatible type "list[int | str]"; expected "list[int]" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Which makes sense, if we think about what incr_append
is expecting in that else
branch:
we assume a nonempty list is ending in an integer, and it might not be if we allowed list[int | str]
!
incr_append
can also show us what goes wrong in the converse case, where the types mismatch in
the other direction.
Maybe we want to allow strings which represent integers.
def incr_append(data: list[int | str]) -> None:
if not data:
data.append(0)
else:
last = int(data[-1])
data.append(last + 1)
(We’ll assume we’re okay with int()
raising an error on bad inputs.)
That fixes the problem on the snippet we just had!
items: list[int | str] = [1, 2]
incr_append(items)
print(items)
will now type-check okay in mypy
.
But what if we change it, to…
items: list[str] = ["1", "2"]
incr_append(items)
print(items)
Well, it works again at runtime, but it’s clear now why it should fail type-checking:
$ python script.py
['1', '2', 3]
$ mypy script.py
script.py:10: error: Argument 1 to "incr_append" has incompatible type "list[str]"; expected "list[int | str]" [arg-type]
script.py:10: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
script.py:10: note: Consider using "Sequence" instead, which is covariant
Found 1 error in 1 file (checked 1 source file)
items
was a list of strings, but we accidentally changed it to start containing integers.
Handling Invariance by Declaring More Variants
mypy
suggests using the Sequence
type, which has some nice properties, but
may require you to restructure your code.
In some cases, a nice way to handle invariant types is to simply get a little bit more explicit about exactly what your functions support.
For example, the version of incr_append
which accepts list[int | str]
won’t allow us to use it
on a list of strings (good! it appends integers!), but it also won’t allow us to use it on lists
of integers!
The type checker can’t tell “how” we aren’t matching types, and the invariance
rule is strict.
So this code won’t type-check:
def incr_append(data: list[int | str]) -> None:
if not data:
data.append(0)
else:
last = int(data[-1])
data.append(last + 1)
items: list[int] = [1, 2]
incr_append(items)
even though we can see that it’s perfectly safe.
One simple option is to expand the possible types for the input to incr_append
, like so:
def incr_append(data: list[int | str] | list[int]) -> None: ...
Now, we’re declaring that incr_append
accepts either a heterogeneous list of
integers and strings or a homogeneous list of integers.
In either of those two cases, append(last + 1)
is safe.
When you know what all of the variations are, you can write down all of the individually invariant types you support and get a good result.
Avoiding Invariance by Using Immutable Containers
mypy
suggested using Sequence
“because it is covariant”.
Perhaps a better way to understand the advantage of Sequence
, at least
vis-a-vis list
, is that Sequence
is immutable, because it only describes a
small subset of the capabilities of list
, and it doesn’t include mutation.
Being immutable is why Sequence
is “covariant”.
Unlike list
, Sequence
would not allow for a call to append()
.
As a result, many of the concerns about accidentally adding the wrong type of
thing to a list disappear.
Consider the earlier example, updated to use Sequence
:
from collections.abc import Sequence
def sum_the_ints(data: Sequence[int | str]) -> int:
integers = [x for x in data if isinstance(x, int)]
if not integers:
return 0
return sum(integers)
Because the type checker can see that data
doesn’t necessarily support append()
,
this is now safe to call on a list[int]
.
Ȍ̶̺h̵̢͉̐ ̵̣͔͐n̴͉̫̿o̷̯͊,̸̨́͊ͅ ̴̰̮͌i̵͕͛̋ṣ̵̼͋í̵̫͋n̵̰̏s̷̭̳̐ṫ̶͖̞ȧ̷̗̺̚n̷͈̍c̷̱̩̃̀ë̸͉̘́͝(̸̖̈̐)̴̖̙́̅!̵͔̬͝
Nothing is sacred!
“Sequence
is immutable” is one of the many soundness holes in the Python type system!
Python is a very dynamic language. There are a number of well known cases which type checking cannot possibly catch statically.
It turns out, Sequence
combined with type narrowing leads to one of the more
surprising gaps.
Consider this version of incr_append
from before, which returns a new list for
any sequence input:
def incr_append(data: Sequence[int | str]) -> list[int | str]:
if not data:
return [0]
else:
last = int(data[-1])
return list(data) + [last + 1]
This works nicely. But what if…
Let’s just try something.
Right now incr_append()
is always making new lists.
And maybe, by the way it’s used in practice, we never want to use a list after
it has been rebuilt.
So it actually would be a little bit nicer if we could just do an append()
in that case, and
return the original list.
That way, we we’ll be a little bit more efficient.
So let’s check if data
is a list
and add a special case for that!
Here’s the full example:
from collections.abc import Sequence
def incr_append(data: Sequence[int | str]) -> list[int | str]:
if not data:
return [0]
else:
last = int(data[-1])
if isinstance(data, list):
data.append(last + 1)
return data
else:
return list(data) + [last + 1]
items: list[str] = ["1", "2"]
incr_append(items)
print(items)
Did we do a good job, does it type check?
mypy script.py
Success: no issues found in 1 source file
And if we run it?
$ python script.py
['1', '2', 3]
Oh dear.
Recap
Type variance describes how a generic type – the easy cases to think about are containers – interacts
with its type arguments. It’s “how list
interacts with int
in the type expression list[int]
”.
Invariance is the property of list
, or any other generic type, which says “in order for usages to be
considered safe, the type arguments must be an exact match”.
Mutable container types are typically invariant because you need read and write access to be safe.
Immutable container types are typically less strict (covariant) because you only need read access to be safe.
Type narrowing, as with isinstance()
, makes it possible to coerce a
covariant type to an invariant one.
Type checking in Python is known and understood to be incomplete.
The demonstrated soundness hole is not a bug in mypy
or any other particular
checker – it’s a characteristic of the current limitations on what we can do
with static types.