From Zero to Types
This article explains all you need to know about type hints (for now). If you know how to write Python functions, you should be well equipped to read this.
What are type hints?
Type hints are optional annotations that you can put on your functions and classes to suggest what kinds of values they're intended to deal with. For example:
# without type hints
def find_match(pattern, strings):
for i, string in enumerate(strings):
if re.match(pattern, string) is not None:
return i, string
return None
# with type hints
def find_match(pattern: str, strings: list[str]) -> tuple[int, str] | None:
for i, string in enumerate(strings):
if re.match(pattern, string) is not None:
return i, string
return None
This reads as:
- the
pattern
argument should be a string - the
strings
argument should be a list, where each element is a string - the function returns either an
(integer, string)
tuple orNone
Why would anyone do that?
Documentation
Type hints serve as formal documentation: it's a standardized way to explain to other developers how to call this function. "Other developers" includes you two weeks later
Error checking
Type hints don't do anything at runtime: you're free to call find_match(42, socket.socket())
and get a nasty error like TypeError: 'socket' object is not iterable
.
However, you can run an external tool (called a "type checker") that can find such mistakes without running the code:
This can seem trivial: this function clearly works with strings, why would you call it
with bytes
? Or a socket?! And this should be caught with the most basic unit test.
-
Without the type hints, you'd have to examine the implementation of this function to know what argument types it expects. In a real codebase, you might have to dig through several layers of calls to figure out the interface of a function.
-
It's not always easy to see that you're not using a function correctly. For example, you might forget that
find_match
returnsNone
when a match isn't found and write code like this:A type checker will remind you thatdef find_phone(fields: list[str]) -> str: _i, phone = find_match("^[-+0-9()]{1,15}$", fields) return phone
find_match
can returnNone
, which you won't be able to unpack like this.
Editor support
Type hints allow you to write and read code more effectively in your editor.
How to get started
Configure your editor
- Install the "Pylance" extension (or the "Python" extension which includes Pylance)
- Go to Settings ( in the bottom-left corner)
- Search for "type checking mode"
- Switch the setting from "off" to "standard"
No need to install anything, you should be good to go.
If your editor supports the Language Server Protocol, you can use Pyright with it. Search for "[your editor] pyright" in your favorite search engine and you'll find the right instructions
You can run mypy
or pyright
in the command line:
I like pyright
better, but mypy
is older and more popular.
I don't want to install anything
You can play around with types at the Pyright playground
Run a basic example
def add_squares(x: int, y: int) -> int:
return x**2 + y**2
print(add_squares(10, 20))
print(add_squares("42", "hmm"))
You should see a warning to the effect of "x
is supposed to be an int
, but you provided a string"
Remember, no effect at runtime
Try a different example:
You should see a similar warning from your type checker. However, if you execute this code,
Python doesn't complain and simply prints 4242
.
Different kinds of types
Let's go over different things that you can annotate your functions with. In Python, a "type" is often
synonymous with a "class" (see type([1, 2, 3])
for example). However, type hints can be more detailed.
For example, list
on its own is not very useful: what's in the list? But you can use list[int]
to
denote that every element of the list is an int
.
Classes
A class is the simplest annotation you can have. You've already seen it in action in this tutorial.
Union
Sometimes you want to accept or return either one class or a different class. This can be done with
the pipe |
operator.
def indent(string: str, by: int | str) -> str:
if isinstance(by, int):
return indent(string, " " * by)
else:
return by + string
The first argument is a string, and the second argument is either an integer or a string.
None
The None
object is special. You don't need to specify the class of None
, instead you just write None
.
Defaults
Note that the default value for an argument is written after the annotation.
It's often used in combination with |
, because accepting "something or None
" is very common.
-> None
Remember, if a function doesn't execute a return
statement, it returns None
. In that case
you should annotate it with -> None
.
-> None
doesn't mean the same thing, it means that you forgot to specify the return type.
Types with parameters
Collections such as list
, dict
, set
require parameters when you use them in type hints.
def is_nice(numbers: set[int]) -> bool:
return 69 in numbers or 420 in numbers
# dict needs two parameters, separated with a comma
def count(strings: list[str]) -> dict[str, int]:
counter: dict[str, int] = {}
for key in strings:
counter[key] = counter.get(key, 0) + 1
return counter
Empty collections
Whenever you have an empty collection assigned to a variable, you need to give it an annotation:
If you just donames = []
, the type checker will have no idea whether this list is supposed to
contain strings, numbers, dogs, or a combination of those.
This is a "variable annotation" as opposed to a parameter annotation or return type annotation.
If you just don't care
If your static analysis tool of choice refuses to cooperate, you can use typing.Any
.
Any
lets you do absolutely anything with an object.
from typing import Any
def resurrect(being: Any) -> None:
being += 1
being.quack()
for cell in being:
cell.meow()
json.loads()
and pickle.loads()
both return Any
.
This is handy, but don't overuse Any
. By design, Any
will prevent type checkers from
detecting all the "wrong stuff" you will do with the value.
If you want to use Any
, read these first:
Type inference
Great, now you know how to annotate function parameters. But what about all the stuff that happens inside a function?
If a value is not explicitly annotated, a type checker will infer its type. It will look around that value and try to deduce what its type is. For example:
def count_f(string: str) -> int:
small = string.count("f")
big = string.count("F")
return small + big
Type checkers know that str
has a count
method expecting a string
and returning an integer. Given that, it deduces that small
and big
are
int
s.
You can see what type your type checker infers for a variable by hovering over it:
This is why you shouldn't annotate most of your variables: the type checker will already know what type it is.
But wait, there's more
What you've learned so far is more than enough to get started. Try using type hints in your next project. However, there's much more to type hints, and you might need more advanced things in the future.
- Read more articles on this website
- typing documentation
- mypy cheat sheet
- The
#type-hinting
channel in Python Discord