Prefect Logo
Prefect Product

Why We Went All In on Type Completeness

January 30, 2025
Alex Streed
Staff Software Engineer
Share

Like all great journeys, ours began with a messenger from afar. In our case, a GitHub discussion opened by Martijn Pieters brought our attention to the incomplete type annotations on our public interfaces. Martijn was looking for a workflow orchestration framework and saw Prefect's use of type annotations to help users validate their code as a strong point. There was a catch: Prefect was missing type annotations in some key areas and wasn’t “type complete.”

When we say type complete, we mean every public interface in our codebase—every function, method, class, and attribute that users might interact with—has accurate and comprehensive type annotations. This goes beyond just having some type hints here and there; it means ensuring that all parameters, return values, and class attributes are properly typed and that these types are actively enforced through a static type-checking tool like pyright.

Up to this point, we had tried our best to annotate our classes and functions with type hints, but we lacked any automated enforcement to ensure all interfaces were correctly typed. This lack of accountability meant we had some blind spots in our typing, but even without any tooling to keep us honest, we had type annotations on two-thirds of our public interfaces! If we were already two-thirds of the way there, surely the final third would be smooth sailing!

What a foolhardy thought.

Fortunately, Martijn volunteered to assist with the type completeness initiative, and his contributions were invaluable in reaching type completeness.

Like all great journeys, this one was well worth the work involved, but it wasn't easy, and if we were to go back, we wouldn't change a thing.

Types Maintain Trust in Your Code

Before going all in on type completeness, we must ask, "Why bother with typing at all?" You could make the case that type hints in Python are superfluous: they don't change runtime behavior, reduce the readability of otherwise beautiful code, and cut against Python's dynamically typed design. While there's truth in these arguments, typing can bring safety to the more flexible aspects of Python, like duck-typing and multi-type lists. Additionally, type hints are essential when creating a development tool like Prefect that enhances user-provided code to maintain inspectability.

For example, let's say I have a function with type annotations:

1def add(a: int, b: int) -> int
2    return a + b

When using this function in my IDE, I can easily see the types of arguments that need to be passed into it and the type of object it will return. I can use static analysis tools like pyright and mypy to catch potential errors before runtime. If I try to add "apple" and "orange", I'll get a red squiggle in my IDE and pyright will give me a stern talking. If I change the internals of my function to cast the arguments to strings and return a concatenated string, mpy will tell me how disappointed it is in me. Using tooling along with types helps your exceptions and reality stay in sync.

Prefect offers decorators to elevate functions to workflows, so if I wanted to take my add function and turn it into a workflow, I'd add an @flow decorator like so:

1from prefect import flow
2
3@flow
4def add(a: int, b: int) -> int
5    return a + b

Adding a @flow decorator will wrap my function in a Flow class and imbue it with additional observability and resiliency. Because we're wrapping existing typed code, if @flow is not correctly typed, then my add function is no longer inspectable and none of my tooling will warn me if I try to add "apple" and "orange". Even worse, add won't raise at runtime with two string arguments, and I may not get an exception until later in the execution. At this point, it'll be more difficult to debug the original problem. When we leave out types, we no longer benefit from automatically checking if our expectations and reality have diverged.

By including type hints with all of our interfaces, we ensure that using Prefect enhances the trustworthiness of your code instead of degrading it.

Types Are Documentation that Never Go Out of Style

When introducing a new library or module into your code, there's always a bit of a learning curve. The steepness of that learning curve can often depend on the kindness of strangers (a.k.a. the person who last changed the code).

Kind strangers will include docstrings explaining how to use a function or a class, but docstrings can be notoriously hard to keep in sync with code. I'm sure many of us have been in a situation where we added a parameter to a function or an attribute to a class without updating the corresponding docstring. Including types on all parameters is a good baseline to ensure you're giving consumers of your code enough information to use it effectively.

If a parameter is missing from the docstring and there's no type hint, then you better hope the parameter is named well enough to give you a fighting chance.

Types Enhance the Developer Experience

Modern IDEs use type hints to provide rich autocompletion, inline documentation, and real-time error detection. Without proper typing, these features degrade in their usefulness.

While working on improving our type completeness, we saw firsthand the improvement in developer experience. After fixing generics that weren't fully defined, our IDEs were able to highlight cases where we could run into unhandled exceptions at runtime. Using functions and methods from the codebase also became more straightforward because the type completion improved as we added missing types.

Type hints transform the development experience from reactive to proactive by providing feedback before the code even runs. This immediate feedback loop is particularly valuable when working with workflow orchestration. When building data pipelines that run hours or days after you've deployed them, catching errors during development is crucial. A type error that makes it to production might not surface until much later, and finding what went wrong will likely take much longer.

Types Help Scale a Code Base

Types are crucial to scaling a codebase. Once a codebase gets large enough and many developers are working in it, you can't hold it all in your head. It becomes impossible to know all the components of the codebase well enough to verify it's all working together in harmony. Type hints enable automated checks on the code to ensure there aren't any mismatched expectations between components and enable developers to work together without stepping on each other's toes.

We discovered this firsthand when improving the typing on our orchestration policies. In Prefect, users can separate their workflows into tasks and flows with separate policies governing the behavior of these two primitives. To implement these policies, we have a BaseOrchestrationPolicy class that all of our policies inherit from, but certain policies rely on flow-specific features, others rely on task-specific features, and still others are generic between the two types. The lack of specification in our typing meant that we had some wonky inheritance structures, which made contributing to these policies a bit of a minefield. While updating the type hints in these modules, we rationalized our inheritance structure and improved the guardrails for contributing new policies or updating existing policies.

Issues of scale like this are magnified in open-source codebases. Projects like Prefect receive contributions from hundreds of different developers, and the organization and hygiene of the codebase directly affect the ease of contribution. Type hints illuminate the intention for different pieces of code and make it easier to make changes with confidence it won't break other parts of the system.

Types Aren't Just for Humans

Type hints serve a purpose their original authors might not have envisioned: they help Large Language Models (LLMs) generate better, more accurate code for your library.

When an LLM tries to generate code that uses Prefect, it's in the same position as a developer encountering our library for the first time. Unlike humans, who might skim documentation or glance at example code, LLMs are adept at understanding and utilizing type information. A well-typed codebase provides clear, unambiguous signals about how components should interact.

For example, if an LLM sees a function that returns a generic type State[Any], where the type parameter represents the return type encapsulated by the State, it might generate code that doesn't correctly handle the wrapped return value. However, proper typing, such as State[int], can generate more precise code that correctly handles the expected return value.. The difference really shows when dealing with our more complex interfaces. Type hints help AI assistants understand the constraints and requirements of our SDK, leading to generated code that's syntactically correct and semantically meaningful.

This benefit extends beyond just code generation. When users ask AI assistants questions about how to use Prefect, the presence of complete type information helps these tools provide more accurate answers and examples. It's another way that investing in type completeness helps our users, even if they're getting their assistance from silicon-based developers rather than carbon-based ones.

The Road Ahead is Well-Typed

When we embarked on our journey to type completeness, we knew it would improve our codebase, but we didn't anticipate just how transformative it would be. What started as a response to a GitHub discussion has become one of our core engineering principles: types aren't just nice to have; they're essential.

Our development team experiences them firsthand through enhanced IDE support and fewer runtime surprises. Our open-source contributors benefit from clearer interfaces and more confidence in their changes. Our users get better error messages, improved documentation, and more reliable code. AI assistants can better understand and work with our codebase, amplifying our ability to help users succeed with Prefect.

That final third of type coverage we initially thought would be "smooth sailing" turned out to be quite the adventure. We uncovered edge cases, debugged stubborn interfaces, and occasionally had to rethink our approach to certain problems, but each challenge we solved made our codebase more robust, maintainable, and accessible. If you want a deep dive into the challenges we had to overcome to reach type completeness, stay tuned for a follow-up post where we'll discuss how to effectively use generics, the magic of if TYPE_CHECKING, and more!

Type completeness is ultimately about building trust—trust between developers and their tools, trust between different parts of a complex system, and trust between humans and machines working together to write better code. In a world where software systems are becoming increasingly interconnected and AI-assisted development is becoming the norm, trust is more valuable than ever.

So, if you're on the fence about going all in on type completeness, we encourage you to take the plunge. Your future self, collaborators, and even your AI assistants will thank you for it.