DEV Community

Cover image for from typing import FinallyAnExplanation
Frank Snelling
Frank Snelling

Posted on

from typing import FinallyAnExplanation

Typing in Python has been a long evolution. A long evolution attempting to reconcile the flexibility of dynamic typing with the safety of static typing.

Quoting from The Zen of Python (PEP 20), I would definitely argue that this principle:

"There should be one — and preferably only one — obvious way to do it."

does not hold true for typing in Python. There is definitely not one way to do typing and the right way is definitely not obvious — unless you take the time to understand its evolution. Which is exactly what we're going to do.

So without further ado, here are some "typing quirks" in Python and how to know when to use what. By using the most modern approach for your project, you can prevent backwards compatibility becoming an eternal migration.

But first. Let's establish two key terms for this discussion.

  • Typing: Typing is describing the intended shape and behaviour of values. It can be done in many ways, such as using str or int to indicate expected types. Typing is optional in Python and does not change runtime behaviour.
  • Annotation: An annotation is metadata attached to code via : or ->. It's stored in an object called __annotations__. The most common thing placed in an annotation is a type (but it can store other stuff too).

my_var: int = 3
Enter fullscreen mode Exit fullscreen mode

Here, my_var is annotated as the type int.


During static time, before Python runs code, type-checkers like MyPy will use the typing information inside annotations to analyse your code.

 When do I use the typing library?

Standardized typing in Python dates back to 3.5 (see PEP 484). The first standard solution to typing was a new (and aptly named) library called typing. In Python 3.5 list[int] was not a type. That's why you would use from typing import List for types lacking syntax.

These type hints took advantage of annotations, a feature which was introduced in Python 3.0 (see PEP 3107).

Typing using built-in generics, like list, was introduced in Python 3.9 (see PEP 585), while the use of | for union types was introduced in Python 3.10 (see PEP 604). Since 3.10, the only reason to use the typing library is for "typing-only" ideas like Protocol and TypedDict.

But the introduction of typing created fresh challenges.

The problem? After static time, while running code, Python would also try to evaluate types (because they're sitting in your code). This means two big problems:

  1. Forward references: when you use a class (or model or method) before declaring it.
  2. Circular imports: when two classes (or models or methods) import each other, causing a deadlock.

Circular imports were addressed soon after the initial release of 3.5.

PEP 484 goes into a lot more detail about this issue and proposes a solution(s), which was introduced in 3.5.2 (see reference):

from typing import TYPE_CHECKING
Enter fullscreen mode Exit fullscreen mode

TYPE_CHECKING is a variable that is True during static time and False during runtime. This lets you import modules when they're needed for static time without impacting runtime at all.

if TYPE_CHECKING:
     from myFile import myClass
     import pandas as pd 
Enter fullscreen mode Exit fullscreen mode

I use these two examples because they illustrate the two main uses of TYPE_CHECKING. The first is already discussed: by only importing myClass at static time we can avoid circular imports. The second use is for heavy libraries which are only needed for type checking, and would be wasteful to load in at runtime when they are not used.

However, if we don't import myClass at runtime we can't actually refer to myClass in our annotations — because runtime Python won't know it exists! When a function was defined at runtime, the actual object in the annotation was looked up and stored in __annotations__ - raising errors if it did not exist.

This is where it becomes necessary to use strings for annotations. For example:

def myFunction(myInstance: "myClass"):
Enter fullscreen mode Exit fullscreen mode

By wrapping the object in strings, it would not be looked up when defined. This not only allowed devs to use TYPE_CHECKING for imports, it also prevented forward references.

Automated stringization was formalized in Python 3.7, called "postponed evaluation".

And that's all postponed evaluation is. A fancy phrase for automating something that was already being done. By including from __future__ import annotations, Python would save annotations as strings by default.

It's worth noting that future is a special module for features intended to become default in Python. PEP 563 introduced from __future__ import annotations and the idea was for this feature to become mandatory by Python 3.11.

But this was scrapped and postponed evaluation was never made default in Python.

PEP 563 was superseded by PEP 649 and PEP 749 which introduced a new concept — deferred evaluation. The reason is discussed in detail in PEP 649.

In brief, storing annotations as strings can have problems for runtime users of annotations — which may or may not be using the annotation for typing. To convert string annotations back to the original object was flaky, slow and difficult.

Interestingly, PEP 484 refers to the "hope that type hints will eventually become the sole use for annotations", which clearly never came to be. It also introduces some creative ideas for distinguishing type hints from other uses of annotations and acknowledges that storing type hints as objects does have the benefit of enabling "runtime type-checkers" (which has become very popular with Pydantic!).

So what is deferred evaluation? Doesn't "deferred" = "postponed"?

Kind of. The Oxford English Dictionary does define the adjective "deferred" as:

"Postponed, put off for a time, delayed."

But in the context of Python, postponed and deferred evaluations are different things.

Deferred evaluation does not stringize annotations but stores them without evaluating them. Each function, class, and module with annotations gets an internal __annotate__ function. Annotations are only evaluated when requested. All happening without using strings.


 Now is a good time to be precise about what "evaluation" exactly means.

Evaluation means that Python determines the value of something (either by executing it or resolving it). For example:

1 + 2 # evaluates to 3 by execution
Enter fullscreen mode Exit fullscreen mode

In this case:

class A:
    b: B
Enter fullscreen mode Exit fullscreen mode

B is evaluated by resolution, which means finding the class B (i.e., the value of B).

Deferred evaluation does not evaluate the class B until it's explicitly needed by the users of annotations. This prevents forward references.

On the other hand, postponed evaluation is not really "postponed". Because it is evaluated. It's just evaluated as a string and then messily converted back to the type.

So — compared to postponed evaluation — deferred evaluation truly is "deferred". It is not evaluated as a string or as anything. It is simply not evaluated until someone uses the annotationlib library, which provides annotations in different formats for different static and runtime users. For example:

get_annotations(A)
# returns real Python objects 
# {"b": <class B>}
Enter fullscreen mode Exit fullscreen mode
get_annotations(A, format=Format.STRING)
# returns strings
# {"b": "B"}
Enter fullscreen mode Exit fullscreen mode

There's a lot of other cool tricks happening under the hood of deferred evaluation including caching and "fake globals" environments. You can read about these in the PEPs. But the key takeaway is that the need for previous workarounds is meaningfully reduced and life is easier for Python libraries who rely on annotations.

Deferred evaluation was implemented in Python 3.14. __future__ annotations is planned for deprecation when Python 3.13 reaches the end of its life (but this will likely be delayed). This highlights the importance of migrating to new techniques when possible, especially when writing fresh code in newer versions.

So if you're using Python 3.14+ and not supporting older libraries, you can forgo many of the workarounds, including:

  • "typesAsStrings"
  • __future__ annotations

One thing that is still useful in 3.14 is the typing library. Not only for typing information that does not exist as built-in generics, but also for if TYPE_CHECKING. While forward references have been addressed through deferral, circular imports remain unaddressed. The primary other options are to place import statements where they are actually used: for example, inside functions. Or to use a separate types.py module. These options are respectively non-idiomatic and fragmented.

I find my AI agent often uses a mishmash of Python typing strategies.

The reason I decided to do this deep dive was because I found my AI agents mixing various typing techniques and workarounds with no apparent correlation to the version of Python my project supports. I have a couple of theories why this happens:

  1. If a certain typing technique (like __future__ annotations) exists already in a project — perhaps as an LLM mutation or legacy code — this may be replicated and amplified in a self-reinforcing way, even if it is not always necessary.
  2. LLMs are generally optimized for working code. Also, LLMs will optimize for our own objectives. And it's easy for us to also optimize for working code over best practice. Using the typing library for List may be unnecessary but it will never break anything. So if you're only optimizing for working code, you might as well use List everywhere!

Regardless, these peculiarities highlight the importance of understanding the evolution of typing. Because using outdated strategies could make migration harder when things like __future__ annotations are deprecated down the line.

So, to recap:

Can I use standard typing in Python?
Yes, if you are using >3.5.

Should I use the typing library?
Only when you need to.

Should I put my types in strings?
Maybe. But probably not.

  • Yes, if you are using <3.7.
  • No, if you are using >=3.7:
    • If >=3.7 and <3.14, use __future__ annotations if you need stringized annotations.
    • If >=3.14 you will rarely (if ever) need stringized annotations.

Should I use if TYPE_CHECKING?
This is mainly useful in two situations:

  • A heavy library only needed for type checking.
  • Circular/forward import statements.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.