The Lie We Tell Ourselves
I used to think my code was safe. mypy
was happy. My IDE flashed a reassuring green checkmark. My Pydantic models were beautifully annotated. “Of course, this Message[int]
knows it’s holding an integer,” I’d tell myself. “The types are right there.”
Then I tried to ask my model, at runtime, what type it was holding. The response was a confused shrug.
It turns out there are two worlds: the pristine, orderly world of static type checking, and the chaotic, messy reality of runtime. The map your type checker uses is often thrown away before your code actually runs. This is the story of how to draw a new map—a reliable way to ask your code, “What type are you, really?”
The Two Worlds: Static Blueprints vs. Runtime Reality
Think of type hints like an architect’s blueprint for a house. The blueprint for list[int]
clearly says, “This is a list, and it must contain integers.” mypy
and your IDE are like building inspectors who check the blueprint for errors. They’ll tell you if you try to put a str
where an int
should go.
But when Python runs the code, it’s not looking at the blueprint anymore. It’s standing inside the finished house. All it sees is a list. The [int]
part? That’s just a faint memory, a notation on a blueprint that got filed away.
This is the heart of the problem. We, as developers, need to be able to look at the finished house and figure out what was on the blueprint. We need to do some runtime detective work.
Level 1: The Simplest Clue - Just Ask the Value
Our first tool is the most direct. If you want to know what something is, just ask it. In Python, that’s the type()
function.
|
|
This is our bedrock, our source of ground truth. When you have a value, type(value)
will never lie to you. It tells you what you have right now.
But what if you don’t have a value yet? What if you’re writing a function that needs to know what type of list it’s supposed to receive, even if the list is empty? For that, we need to dig deeper.
Level 2: Reading the Blueprint’s Margins with typing
Sometimes, Python doesn’t throw the whole blueprint away. For special objects from the typing
module, it keeps some notes in the margins. We can read these notes with two helper functions: get_origin
and get_args
.
get_origin(some_type)
is like asking, “What’s the main container type?” (e.g.,list
,dict
).get_args(some_type)
is like asking, “What are the specific types inside?” (e.g.,int
,str
,float
).
Let’s see them in action:
|
|
This feels powerful! It seems like we’ve solved it. But a new villain enters the scene, and this is where most developers get stuck.
The Trap: What happens if you use these on a normal class?
|
|
Nothing. These tools only work on the special constructs from the typing
module, not on regular classes. And as it turns out, a specialized Pydantic generic like Message[int]
behaves a lot more like a regular class than a typing construct.
The Boss Level: Pydantic v2’s Clever Disguise
When you write MyGenericModel[int]
, Pydantic doesn’t just store int
somewhere. It dynamically creates a brand new class on the fly. This new class is a subclass of MyGenericModel
, and it’s been specifically tailored to handle integers.
This is incredibly powerful, but it means our get_origin
/get_args
trick won’t work. We’re dealing with a real class, not a typing annotation. I remember spending hours on this, thinking I was going crazy. “Why can’t I get the int
out of Message[int]
?!”
The secret is that Pydantic leaves clues for us inside this new class. We just have to know where to look. There are two reliable spots:
- The Secret Metadata Pouch: A hidden attribute called
__pydantic_generic_metadata__
. This is the most direct and precise clue, telling us exactly whatT
was specialized with. - The Public Field Annotation: Pydantic updates the
annotation
on the model’s fields. So, on aMessage[int]
class, thecontent
field’s annotation is no longerT
, butint
.
The Grand Unifying Strategy: A Three-Layer Forensic Kit
So, how do we combine all this knowledge into a single, reliable strategy? We build a function that checks for clues in the right order, from most specific to most general.
First, we need a little helper to make our type names readable. Think of it as a magnifying glass that works on any kind of clue.
|
|
Now, we can build our master detective method inside our generic Pydantic model. We’ll use a @computed_field
to make this information easily accessible.
|
|
Let’s test our detective kit:
|
|
It works! This three-layer strategy is robust. It prefers the precise design-time information when available, but gracefully falls back to the undeniable truth of the runtime value.
Side Quest: Taming Forward Refs and Circular Nightmares
Sometimes, you have to define models that refer to each other before they’re fully defined. This is common in things like ORMs or complex API schemas.
|
|
This creates a paradox. How can Python understand A
without knowing B
, and vice-versa? The string 'B'
is like an IOU for a type. The problem is that when it’s time to cash in that IOU, Python needs to know where to look.
If your models are defined inside a function, the names A
and B
might only exist in that function’s local scope. When you try to resolve the types later from a different scope, Python can’t find them.
The solution is to give Python a map. You capture the namespace (the dictionary of local and global names) where the models were defined and provide it when you ask for the type hints.
|
|
If you keep your models at the top level of a module, you often don’t need to worry about this, as Python’s default global scope is usually enough. But the moment you start defining models inside functions, this localns
trick is a lifesaver.
Your New Mental Model
Stop asking, “Why won’t Python give me the type?” Start asking:
What am I inspecting (a blueprint, a class, or a value), and do I have the right map (the scope) to find what I’m looking for?
With this mental model, runtime type introspection stops being a frustrating mystery and becomes a straightforward process of investigation. Your Pydantic generics will no longer feel like a black box, but a powerful tool you can confidently inspect and understand.
(Written by Human, improved using AI where applicable.)