# Type hints and pyright: read a client's codebase fast and catch None errors before runtime

> You haven't changed a line in the client's repo yet, but pyright can already tell you which functions will return None when real data is missing a value.

Original: https://fdetimes.net/en/guides/python-type-hints-pyright-none-errors/

It is your second day on site. You have been given access to a Python repo that is several years old. The engineer who wrote the billing code has left, and every function has parameter names but not a single annotation. By Friday you have to connect your pipeline to that exact code.

The usual approach is to read file after file, scatter `print` calls everywhere and run it against sample data. That is slow, and it misses the kind of bug that most often brings down a real deployment: a function that always returns a value with the sample data but returns `None` when the client's data is missing a row.

Type hints, paired with a type checker such as pyright, deal with both problems. You read unfamiliar code faster because you know what each function takes and returns, and you catch errors before anything runs. FDEs almost always work on code they did not write, so this skill pays for itself.

## If Python doesn't check types, who does?

Get this wrong and everything that follows goes wrong too, so it is worth being clear from the start. The official documentation for the `typing` module states that the Python runtime does not enforce function and variable type annotations. Annotations are there for third-party tools to read: type checkers, IDEs, linters.

That was the design from the beginning. PEP 3107 introduced the syntax for function annotations but deliberately left their meaning undefined. Only with PEP 484 were annotations used to indicate types. PEP 484 itself states that Python will remain a dynamically typed language and that the authors have no wish to make type hints mandatory.

For you, this has two consequences. Writing `def f(x: int)` does not stop anyone passing in a string. On the other hand, adding hints to a client's code does not change how that code runs, so the work carries almost no risk. Hints only become useful when a tool reads them.

## Why pyright suits code without hints

Pyright is a static type checker. You can install it as a language server in your editor or use the command-line version. According to pyright's documentation, you do not need full annotations to use it: the more type information there is, the more it can help.

With legacy code, the important point is that pyright does not wait for you to write hints. According to its documentation, under the default configuration it checks all code, whether annotated or not.

It infers a variable's type from the value assigned to it. A function's return type is taken to be the union of the types of every `return` statement. Where it cannot infer anything, pyright treats the type as `Unknown`, a form of `Any`.

Mypy behaves differently by default. According to the same comparison document from pyright, mypy skips every function and method that has no type annotations.

This is a trap. On a repo without hints, a clean mypy run does not mean the code is fine. It only means mypy has not read most of the code.

Pyright's documentation also says that on large codebases it is common for pyright to be 3 to 5 times faster than mypy.

## One None bug, from start to finish

Picture the regional billing code below. It ran fine on its author's machine, because the test data always included the EU region.

```python
def load_rate(rows, region):
    for row in rows:
        if row["region"] == region:
            return float(row["rate"])
    return None

def price_with_tax(rows, region, price):
    rate = load_rate(rows, region)
    return rate * price
```

There is not a single annotation. You run `pyright` on this directory without changing anything. `rows` has no type, so pyright treats `row` as Unknown. But the two `return` statements alone are enough for it to infer that `load_rate` returns `float | None`.

The consequence is on the last line. There, `rate` has the type `float | None`, and multiplying `None` by a number will stop the program. Because this type information is available before anything runs, that line is the first place to look when you read pyright's output. It points straight at the situation that breaks an invoice: the client has a region that is not yet in the rate table.

With mypy under its default configuration, both functions would be skipped because they have no annotations.

The fix comes in two steps. The first is to state the intent with type hints. Mypy's cheat sheet recommends writing `X | None` for a value that may be `None`; `Optional[X]` means exactly the same:

```python
def load_rate(rows: list[dict[str, str]], region: str) -> float | None:
    for row in rows:
        if row["region"] == region:
            return float(row["rate"])
    return None

def price_with_tax(rows: list[dict[str, str]], region: str, price: float) -> float:
    rate = load_rate(rows, region)
    if rate is None:
        raise ValueError(f"No tax rate for region {region}")
    return rate * price
```

The second step is the `if rate is None` check. After that line, the type checker narrows the type of `rate` to `float`. The code is still short, but it now says clearly what to do when data is missing. This is also the question to take to the client: when a region has no tax rate, should the code raise an error or use a default rate?

**Key point:** A type checker does not understand the code for you. It tells you what to ask the client.

## In what order to work on a client's repo

Step one is to run pyright with the default configuration, before changing anything. According to its documentation, pyright's `typeCheckingMode` has four levels: off, basic, standard and strict, with standard as the default. The first run gives you a map: which files have the most errors, which functions can return `None`, where everything is Unknown.

Step two is to add hints where data comes in first: functions that read files, call APIs, query databases or take input from the client's systems.

The reason is in mypy's cheat sheet: an untyped parameter is treated as `Any`, and with `Any` the tool catches no errors at all.

Step three is to write the configuration into `pyrightconfig.json` on your branch, then add the `pyright` command to CI if the client agrees. Keep the old code at standard. New modules you write yourself can be set to strict, because there is nothing old to clean up.

## Common mistakes

The first mistake is believing type hints protect code at runtime. They do not. If data from the client's API can arrive with the wrong type, you still have to check it yourself at the point where it comes in.

The second is trusting a clean mypy result on a repo without annotations, as in the example above. The third is scattering `Any` everywhere to clear the red errors. The error count looks better, but the tool can no longer check anything.

The last is turning on strict for an entire legacy repo on day one. You will get a long list of errors, the client will see a pull request touching hundreds of files, and the `None` errors that really matter will be buried in the pile.

To put this skill on your CV, record how many errors pyright reported when you took over the repo and which bugs you stopped before they met real data. A line like that shows a hiring manager how fast you get up to speed on an unfamiliar codebase, which a generic "Python" never does.

The next time you are handed an undocumented repo, the first thing to do is run pyright, before writing a single `print`. That first list of errors usually tells you fairly clearly where the client's data can break the code.

**Try this week:**

- Install the command-line version of pyright and run it on a Python repo you are taking over, leaving the code and the default configuration unchanged. Note down the errors related to None.
- Pick a function that reads outside data, add type hints with X | None, handle the None case where the function is called, then rerun pyright until the errors are gone.

## Sources

- [typing — Support for type hints — Python 3.15.1 documentation](https://docs.python.org/3/library/typing.html)

- [PEP 484 – Type Hints](https://peps.python.org/pep-0484/)

- [Pyright docs: Getting Started](https://raw.githubusercontent.com/microsoft/pyright/main/docs/getting-started.md)

- [Pyright docs: Configuration](https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md)

- [Pyright docs: Type Inference](https://raw.githubusercontent.com/microsoft/pyright/main/docs/type-inference.md)

- [Pyright docs: Mypy Comparison](https://raw.githubusercontent.com/microsoft/pyright/main/docs/mypy-comparison.md)

- [Type hints cheat sheet](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html)
