Skip to content

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 or None

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:

Example of pylance error
Running Pylance in VSCode

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 returns None when a match isn't found and write code like this:

    def find_phone(fields: list[str]) -> str:
        _i, phone = find_match("^[-+0-9()]{1,15}$",  fields)
        return phone
    
    A type checker will remind you that find_match can return None, 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.

Pylance autocomplete example
Autocompletion for a method in VSCode
Pylance actions example
Navigating your codebase in VSCode

How to get started

Configure your editor

  1. Install the "Pylance" extension (or the "Python" extension which includes Pylance)
  2. Go to Settings (âš™ in the bottom-left corner)
  3. Search for "type checking mode"
  4. Switch the setting from "off" to "standard"

alt text

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:

def double(number: int) -> int:
    return number + number

print(double("42"))

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.

class Dog:
    ...

def create_dog(height: int) -> Dog:
    dog = Dog()
    dog.grow(height)
    return dog

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.

def maybe_print(item: str | None = None) -> None:
    if item is not None:
        print(item)

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.

def my_print()
omitting -> 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:

names: list[str] = []
If you just do names = [], 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()
For example, 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 ints.

You can see what type your type checker infers for a variable by hovering over it:

Hovering in Pylance

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.