FDE PulseFDE jobs open 641New in 7 days 48Companies hiring 98Remote-friendly 25%Median US pay $203kTop hirer Databricks 122
VI

The newspaper of the Forward Deployed Engineer

Guides

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.

Type hints and pyright: read a client's codebase fast and catch None errors before runtime
Photo: Emile Perron / Unsplash
ShareLinkedInFacebookX

In brief

  • Python does not enforce types at runtime. Type hints only do anything when a tool such as pyright reads them.
  • By default, pyright checks unannotated code too and infers return types. Mypy skips unannotated functions by default.
  • Add hints where data comes in first, start at standard, and keep strict for newly written modules.
Two code panels side by side. On the left, before any fix, the line return rate * price is highlighted in orange because pyright infers that rate has the type float | None. On the right, after adding a check, if rate is None then raise ValueError, the type of rate on the same line is narrowed to float.
With no annotations at all, pyright still infers that load_rate returns float | None. Add a single None check and the type of rate is narrowed to float.

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.

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:

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?

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.

Was this article useful?

Use with your AI assistantAsk Claude ↗Ask ChatGPT ↗
7 sources
Read next on the roadmap · Stage 1: FoundationsForward Deployed Analyst: the data role between an AI platform and its customersThe person in this role has to ask which questions a customer's data can answer before loading it into the platform. The role is also a way into forward-deployed work for people from data, research or consulting.