"dict[str, Any] is the real type system."
.get("key", {}).get("pls_work") chains with no methods, no encapsulation, no runtime validation.
the result is 2,386 .get() calls, 312 isinstance(x, dict) guards, and 119 .setdefault() patches across the codebase.
dict[str, Any] is the real type system. config is dict[str, Any], detail is dict[str, Any], state gets passed as dict[str, Any]. the TypedDicts and dataclasses are a layer of aspiration over a runtime reality of untyped dicts.
this is what pydantic was invented for. you're hand-rolling a worse version of it across every file that touches state.Senior engineer @banteg posted that on X in February 2026 after an afternoon inside someone else's 60,000-line Python codebase. The thread broke 110,000 views in days because every engineer who has ever inherited a typed codebase that was secretly untyped saw themselves in the audit.
Imagine you are reading the codebase for the first time. The README promises structured types. What you find is two thousand three hundred and eighty-six dictionary .get() calls. Three hundred and twelve isinstance(x, dict) guards. One hundred and nineteen .setdefault() patches. The IDE shows you typed fields. The runtime tells you nothing of the kind. Every developer who touched this codebase had to do the type checker's job by hand, file by file.
The mistake had a name. The codebase had reached for TypedDict, which is Python's static-only type that exists for the IDE and the type checker. It needed Pydantic, which validates data at runtime when the data actually arrives. The two systems do not see each other; they live in different parts of the same program. The thread closed with one sentence: this is what Pydantic was invented for, and you are hand-rolling a worse version of it.
This is not a Python failure. It is the same wall every modern language has hit when its types meet a different jurisdiction. Pydantic crossed ten billion cumulative downloads in February 2026, used by every FAANG company and twenty of the top twenty-five NASDAQ companies. The bet pays. But it pays in a currency TypeScript developers do not arrive carrying.
LangGraph is where the two currencies have to exchange. TypedDict on one side, Pydantic on the other, a code-generation step as the only working bridge. I want to tell you what the wall is, why it exists, why TypeScript has its own version of it, and what to do about it on a real codebase this quarter.
The day my codebase met LangGraph
The team I lead at 2muchcoffee runs a CRM platform on FastAPI. Pydantic types every request and every response. SQLModel handles the database. We started integrating LangGraph for agentic workflows this quarter, and the official tutorials open the same way: the state of a graph is a TypedDict, populated by nodes, passed along the edges. The examples are clean. The state is two or three keys. Reading them, you imagine you are about to build agents.
That is not the codebase the tutorials are about to live inside.
Our state carries roughly twenty fields. Most of them come from Pydantic models we already maintain because the same data flows through HTTP routes, into Postgres via SQLModel, and back out through response schemas. The state of the graph needs the same shapes. The first instinct of any Python developer with a Pydantic-heavy stack is the right one: keep the state in Pydantic, do not duplicate it.
The official LangGraph documentation supports this. The library accepts a Pydantic BaseModel as the state shape. It also notes, on the same page, that Pydantic state is less performant than TypedDict or dataclass state because the library re-validates on every node transition. That is a deliberate trade-off, not a hack. We took it. The code reads more clearly with Pydantic state than with the TypedDict alternative we tried for a week.
The friction was not the runtime cost. The friction was the static side. Calling .model_dump() on a Pydantic state object returns dict[str, Any]. The IDE goes quiet. Refactors lose their grip. The type checker stops being able to tell you which fields exist.
That is the wall.
The three TypeScript habits that do not work in Python
For a TypeScript developer, three primitives feel like air. You stop noticing them until they are missing.
The first is Partial<T>. One word turns every field of a type into an optional version of itself. Useful for PATCH endpoints, useful for form state, useful for the dozen daily moments when you want "this thing, but with everything optional." Python has no equivalent. The closest approximation is to mark every field on a TypedDict with NotRequired and add total=False, which works for a TypedDict you control but does not project across other types. For Pydantic models there is no built-in Partial[MyModel]. The runtime workaround uses create_model and loses static typing along the way. The static workaround is a generated TypedDict shadow class. Neither is one line.
The second is keyof T. One word produces a union of the keys of any object type. Python has Literal[tuple(MyTypedDict.__annotations__.keys())] as an attempted workaround, and runtime hacks using MyModel.model_fields. Both are fragile. Neither plays nicely with IDE autocomplete, and neither survives a renamed field without manual updates.
The third is mapped types in general. Pick<T, K>, Omit<T, K>, Record<K, V>, and arbitrary transformations expressed in the type system itself. Python has none of these as native utility types. They are not in the standard library, and they are not in typing_extensions.
Partial<T> in TypeScript is one line. Partial<T> in Python is a code-generation script.
There is real movement on this. PEP 728 (closed TypedDict with extra_items) is scheduled to land in Python 3.15 in October 2026. PEP 827 (draft, "Type Manipulation") proposes Partial, KeyOf, Omit, and Members as native primitives, directly inspired by TypeScript's mapped types. PEP 827 is still draft status; it might ship in 3.16 or 3.17, or it might be reworked. Either way, Python is doing the work.
But none of that work closes the gap this article is actually about. The boundary I keep hitting is not Partial. It is the boundary between Pydantic models and the static type system that TypedDict represents. PEP 728 will not close that boundary. PEP 827 will not close it either. The reason is structural, and it deserves its own section.
What we ship: Pydantic for state, codegen for TypedDict
We made the call on our CRM platform. The state is Pydantic. Anywhere LangGraph specifically wants TypedDict (handler return types in certain patterns, dictionary access in tooling that has not been updated yet, certain reducer signatures), we generate a matching TypedDict from the Pydantic models with datamodel-code-generator, commit the generated file alongside the source models, and re-run the generator whenever the source models change.
The tool is mature. datamodel-code-generator has supported the Pydantic-to-TypedDict direction since 2023 and is on the typical Python data-stack shortlist. PydanType is a smaller alternative if you want a minimal converter. Either tool can run as a pre-commit hook so the generated file never drifts from the source.
Pydantic lives in runtime. TypedDict lives in static analysis. They cannot meet without help.
This feels strange the first time. In TypeScript you write the type once and the system handles the projection through your codebase. In Python you write the model once and then write a script that writes another version of the same shape, intended for a different consumer of your code. The first time it hit our repo, my reaction was the same as any TypeScript developer's: this is supposed to be obsolete by now.
It is not obsolete. It is the architecturally correct response to a structural boundary. The next section explains why.
Where this pattern came from
Every modern programming language has hit some version of this pattern. The language ships with a type system. Developers want to express something the type system cannot reach. The community fills the gap with code generation. Sometimes the language eventually absorbs the codegen into its core. Sometimes the codegen is permanent. The difference is not whether the gap was intentional. The difference is whether the gap exists inside one execution domain or crosses a boundary between domains.
When the gap is inside one domain, the language can eventually absorb it. C macros got absorbed into C++ templates and constexpr. Lombok's getter generation got partially absorbed into Java Records (JEP 395). Go's go:generate directive for generic data structures got absorbed by Go 1.18 generics. The pattern recurs: a deliberate design choice creates a gap, developers fill it with codegen for a decade, the language designers decide the design priority that created the gap is no longer worth its cost, and the codegen pattern declines.
When the gap crosses a boundary, the codegen is permanent. gRPC and Protocol Buffers exist because no single language compiler can dictate the wire format of a different language running on a different machine. The boundary is between languages and the codegen is the only honest answer. TypeScript ships with one of the most expressive type systems of any mainstream language, and TypeScript developers still run prisma generate and openapi-typescript, because the boundary between static types and runtime data cannot be closed without abandoning either side.
Intentional gaps get absorbed. Cross-jurisdictional gaps persist.
The Pydantic-to-TypedDict gap is the second kind. Pydantic lives in the runtime jurisdiction. It validates incoming data, coerces types, raises errors at runtime. TypedDict lives in the static-analysis jurisdiction. It exists only for the type checker and the IDE; at runtime it is exactly a dict. The two cannot meet inside the language without abandoning what each one is for. The codegen step that bridges them is not a workaround. It is the only structurally honest answer, and the same shape recurs every time a language tries to close a similar boundary.
TypeScript hit this wall in 2015. Prisma is how they crossed it.
TypeScript has every utility type Python lacks. Partial<T>, keyof T, Pick, Omit, mapped types, conditional types. The TypeScript type system is the richest one in mainstream use. And yet, TypeScript developers in 2026 still run prisma generate after every schema change. They run openapi-typescript against OpenAPI specs. They run zod-to-typescript and tRPC code generation. They run ts-proto for gRPC services. The TypeScript ecosystem ships more codegen tooling than the Python one.
The reason is the same boundary that Python's typing system is making explicit. TypeScript types live only in the static-analysis layer. Microsoft's design goals for TypeScript include the rule that no type information should reach runtime. The TypeScript type checker cannot inspect a live PostgreSQL schema. It cannot validate an HTTP response payload. So the ecosystem reaches for codegen tools that produce the static type from the runtime source.
Prisma is how TypeScript crossed the runtime boundary. datamodel-code-generator is how Python crosses it.
Same wall, different stack. A TypeScript developer arriving in Python should not interpret the codegen step as a sign that Python is behind. They should interpret it as a familiar pattern in an unfamiliar accent. The thing they did with Prisma against a database schema is the thing Python developers do with datamodel-code-generator against a Pydantic model. The mental model carries directly.
Where Python is actually going
Python is doing real work on its typing system. The 2025 JetBrains Python Developers Survey reported that 86% of developers rely on type hints regularly. The State of Python 2025 credits Pydantic with driving the broader interest in typing the language has seen over the last five years. Adoption is not the problem.
The problem is the intra-jurisdictional gaps the rich type systems of other languages have closed. PEP 728 is the most concrete progress in 2026: closed TypedDicts with extra_items land in Python 3.15 in October. That absorbs the "I want to forbid unknown keys in this dict shape" use case that was the canonical TypedDict complaint for years. PEP 827 (draft, "Type Manipulation") is more ambitious. It proposes Partial, KeyOf, Omit, and Members as native Python primitives, with the explicit framing that they exist to bring TypeScript-style utility types into Python. If PEP 827 ships, it will close most of the intra-jurisdictional gaps a TypeScript developer notices in their first week.
It will not close the Pydantic-to-TypedDict gap. That gap is structural, not ergonomic. Pydantic v3, currently incremental work rather than a typing overhaul, will not close it either. Even if PEP 827 ships verbatim in Python 3.17 and Pydantic v3 lands the most aggressive interpretation of its current roadmap, the runtime validator and the static type system will still operate in different jurisdictions of the language.
The forward look I would offer to a TypeScript developer entering Python this year is direct. Stop waiting. The intra-jurisdictional gaps are on the absorption trajectory. The cross-jurisdictional gap will not be absorbed. Invest in the codegen workflow this quarter and treat it as architecture, not workaround.
What to do on your codebase this week
The cleanest way to know whether your typing pain is on Python's absorption trajectory or stuck across a permanent boundary is to ask three questions about it. Answer them in your codebase, not in your head.
What is the slowest part of your current LangGraph integration, and which jurisdiction does the friction live in?
If you want a second pair of eyes on the typing boundary before it ships another generation of dict[str, Any] workarounds into production, 2muchcoffee can help through the AI development page or the contact form.