Type Tailoring without Macros
The dream would be to build an ideally flexible system that can typecheck any dynamically-typed program. But we are not there yet - and mostly because of the non-disjoint nature of data types in these languages. Dynamic languages allow types to overlap and inherit from each other (for example, in Python a value can be both a string and an iterable), making it nearly impossible for type checkers to reason about all possible type relationships1. Until we build these infallible type checkers, maybe we can explore developer-assisted approaches to nudge our existing systems.
One such approach is type tailoring2. Type tailoring is a way to extend traditional type systems with domain-specific types. While intended for languages with macro systems and runtime checks, the same goal could be realized with extensible type checkers with a plugin interface such as Mypy3 that allow developers to define constructs for domain-specific (or third-party) types or overlay the type system with domain-specific typing rules.
This piece highlights an interesting use case of Mypy plugins: Lazy evaluation inference, and discusses an idealistic design for enforcing custom type-checking behaviour (by preprocessing Type Hints).
Lazy evaluation inference
The Lazy evaluation pattern is commonplace within the python community for delaying the instantiation of objects until they are needed. This approach optimizes memory by creating objects only at the point of first access, rather than at module import time. But because type information about this lazy object is not available until runtime, type checkers are unable to determine types during static analysis. Idioms like these present challenges for gradual type checkers like Mypy, often forcing developers to fall back to the Any type, which weakens soundness guarantees.
Consider this code sample of lazy object instantiation:
|
|
Mypy fails to recognize that foo()
is a method of our lazily instantiated object. By running
reveal_type(lazy_inst.foo())
, we see Mypy resorts to Any instead of capturing None as the return type
effectively losing all type safety for operations on our lazy object.
|
|
This means we lose precision about our lazy object and further interactions with the object just propagate the Any type all up our type analysis.
This is where Mypy plugins come in. By implementing attribute hooks, we can teach Mypy how to correctly type
lazy objects. These hooks intercept attribute lookups during static analysis, allowing us to provide Mypy
with the actual types that will be available at runtime. For our lazy object, we’ll create a plugin that
tells Mypy that any attribute access should resolve to the types of the underlying NewObj
instance.
|
|
When we call reveal_type again, Mypy correctly identifies lazy_inst.foo() as returning None, giving us proper type safety for our lazy instantiation pattern.
|
|
Preprocessing Type Hints
I explored extending this use of Mypy plugins to attempt preprocess type hints dynamically. For instance, consider a poorly typed function returning Any
instead of a more specific type like str
.
|
|
Could a plugin replace Any
with the correct type and enforce type-checking on the updated hints? While I successfully replaced Any
with concrete types4, I couldn’t control when plugins were called during Mypy’s analysis, making it impossible to enforce typechecking on the updated annotations. Hopefully, future Mypy updates will address these limitations.
Github Issues that highlight this problem. TypeScript, Luau and Mypy. ↩︎
You can check out the OG paper about Type Tailoring with Macros ↩︎
Mypy is a static type checker for Python that comes with a plugin system (see: plugin.py) ↩︎
My pre-processing attempt in a Github Gist ↩︎