Adding Optional Static Interface Checking to Julia
TL;DR
Proposing a system for optional static interface checking in Julia (similar to Python’s mypy) that would:
- Add compile-time verification of behavioral contracts for abstract types and traits
- Have zero runtime overhead (purely static analysis tool)
- Improve code discoverability and IDE support
- Make it safer to work with large codebases and extend external libraries
- Be completely opt-in, preserving Julia’s dynamic nature
The goal is to improve Julia’s usability in large-scale projects without compromising its performance or flexibility.
Why The Lack of Behavioral Contracts Is A Big Deal
I’m relatively new to Julia and have been experimenting with its features for a couple of days. While learning the language, I’ve found that the combination of Julia’s dynamic dispatch and abstract types offers incredible flexibility. However, this same flexibility, combined with the lack of interface enforcement, creates significant challenges that I believe are holding back Julia’s adoption in larger projects.
The current situation presents several critical issues:
-
Development Speed: Without static interface checking, developers often only discover missing method implementations at runtime. This leads to a slower development cycle, which is already challenged by Julia’s time-to-first-X issue.
-
Code Reliability: Every unexplored execution path remains liable to errors. This makes it particularly difficult to confidently refactor or modify code in large systems.
-
Poor Discoverability: Behavioral contracts are implicit, making it harder to understand what methods need to be implemented when extending types or using libraries. This significantly impacts readability and collaboration in larger teams.
-
Safety in Library Extension: When extending external libraries’ types, there’s no clear way to know if you’ve implemented all necessary methods until runtime errors occur.
These aren’t just theoretical concerns - they’re practical issues that affect Julia’s adoption in production environments. While Julia’s core design is brilliant and its performance characteristics are outstanding, the lack of behavioral contracts makes it challenging to recommend for large-scale software development where type safety and interface clarity are crucial.
Having basic type safety guarantees and discoverability is something that I think no modern language can do without, especially in the context of a large codebase with many different people working on the same code. While some might argue that Julia’s dynamic nature is incompatible with such guarantees, Python’s successful adoption of mypy shows that optional static checking can provide these benefits without compromising language flexibility.
A Possible Approach
I’d like to propose an idea for adding optional static interface checking to Julia, similar to how mypy provides optional static type checking for Python. This would be purely a development and CI tool, with no runtime impact or performance overhead.
The core idea is to add some syntax for expressing behavioral contracts that could be verified by static analysis tools. Here’s what this might look like:
Abstract Type Contracts
abstract type GenericA end
abstract type GenericB end
struct C end
abstract function foo(a::Abstract{GenericA}, b::GenericB, c::C)
struct A <: GenericA end
function foo(a::A, b::GenericB, c::C)
# concrete implementation
end
The abstract function
syntax would signal that any subtype of GenericA
must implement this function. This could be verified statically without affecting runtime behavior.
Traits (Or Interfaces)
trait Friend
function dap(x::Friend)::String end
function talk(x::Friend)::String end
end
trait Huggable
function hug(x::Huggable)::String end
end
function heart_warming_friend_reunion(x::T) where {T is {Friend,Huggable}}
print(dap(x))
print(talk(x))
print(hug(x))
end
Again, this would be purely for static analysis and documentation, with no runtime overhead.
Behavioral Tests
We could even include interface-specific tests:
trait Iterable
function length(x::Iterable)::Int
function toArray(x::Iterable)::Array{Iterable}
end
Iterable.@tests begin
function test_length(x::T) where {T is Iterable}
@assert length(x) == length(toArray(x))
end
end
Why This Approach?
The success of mypy in the Python ecosystem shows how valuable optional static checking can be. It provides:
- Better tooling support and code navigation
- Earlier error detection
- Clearer interface documentation
- Safer library extension
Most importantly, it does this while maintaining all the benefits of a dynamic language:
- No runtime overhead
- No compromise on flexibility
- Optional usage - use it where it helps, ignore it where it doesn’t
- Gradual adoption possible
Implementation Details
The actual syntax shown here is just a rough proposal - the Julia team would certainly have better ideas about how to make this feel more idiomatic. However, the technical implementation could be relatively straightforward:
Abstract Functions
The abstract function
syntax could be resolved into standard Julia code:
function foo(a::GenericA, b::GenericB, c::C)
throw(MethodError("Required method foo not implemented for type $(typeof(a))"))
end
This makes all abstract behaviors immediately discoverable using methodswith(GenericA)
without any runtime overhead in actual implementations. The static checker would simply verify that concrete types provide their own implementations.
Traits Implementation
Traits could be implemented by translating them to Julia’s existing type system using the holy traits pattern. For example, a trait declaration like:
trait Friend
function dap(x::Friend)::String end
function talk(x::Friend)::String end
end
struct Dog <: Animal is Friend
# struct implementation
end
Would be translated under the hood to:
abstract type FriendTrait end
struct hasFriendTrait <: FriendTrait end
struct hasNotFriendTrait <: FriendTrait end
FriendTrait(x::T) where {T} = hasNotFriendTrait
has_trait(x, :Friend) = FriendTrait(typeof(x)) == hasFriendTrait
struct Dog <: Animal
# struct implementation
end
FriendTrait(x::Dog) = hasFriendTrait
The beauty of this approach is that:
- The actual runtime would still use Julia’s normal duck typing for function dispatch
- Traits provide static verification without imposing runtime constraints
- The implementation maps cleanly to existing Julia patterns
- You can optionally use the traits at runtime via the holy traits pattern if desired
- Works seamlessly with Julia’s union types for more complex behaviors
The static analyzer would verify that types claiming to implement a trait provide all required methods, while the runtime would continue to work exactly as Julia does now. This means we get the best of both worlds: static verification for catching errors early and maintaining Julia’s dynamic flexibility at runtime.
For example, a trait declaration like:
trait Friend
function dap(x::Friend)::String end
function talk(x::Friend)::String end
end
Could be checked statically but wouldn’t need any runtime representation beyond normal Julia methods. This means:
- No performance impact
- No changes to Julia’s core dispatch system
- Full compatibility with existing code
- Natural integration with documentation tools
Static Analysis Integration
The static checking would work similarly to mypy:
- Parse the Julia AST to identify trait declarations and requirements
- Build a graph of type relationships and required methods
- Verify that all required implementations exist
- Provide clear error messages for missing implementations
This could be implemented as a separate tool that runs during development or CI, without requiring any changes to the Julia runtime or affecting production code performance.
Behavioral Tests
The test macros would expand to normal Julia test code, but the static analyzer would ensure that all required test implementations exist. This provides:
- Compile-time verification of interface completeness
- Runtime testing of actual implementations
- No overhead in production code
The key insight is that we can achieve all these benefits through static analysis and existing Julia features, without needing to modify the language’s runtime behavior or compromise its performance characteristics.
Questions and Limitations
As someone still learning Julia, I’m sure there are aspects I haven’t considered or limitations I haven’t spotted. I would particularly appreciate feedback on:
- How this would interact with Julia’s multiple dispatch system
- Whether the proposed approach fits with Julia’s design philosophy
- What challenges this might present for tooling implementation
- Whether there are better ways to achieve similar goals
Looking for Feedback
I really appreciate the thoughtful design of Julia and the amazing work done by the language maintainers. This proposal is offered in the spirit of constructive discussion, and I’m very interested in hearing the community’s thoughts, concerns, and suggestions.
While parts of this system could potentially be prototyped using macros, I believe the real value would come from proper language integration with IDE support and tooling. A macro-based approach would be limited in several ways:
- Inability to provide proper static analysis
- Limited IDE integration capabilities
- Less intuitive error messages
- No cross-module analysis
- Harder to maintain and evolve
Nevertheless, if anyone is interested in exploring a macro-based prototype to test some of these ideas, I’d be very interested in collaborating. This could help validate some concepts while we discuss potential language-level integration.
What aspects of this proposal make sense? What problems do you see? Are there alternative approaches that would work better within Julia’s design philosophy?