Static Typing in Python

1332 Words 26 August 2024

Dynamically typed languages such as Python are notorious for being error-prone. Here are a few techniques for overcoming a type of error which would not occur in statically typed languages.

Dynamic Typing vs Static Typing

When defining a function in a statically typed language, such as C or Rust, one must always declare the function argument types. This practice, while seemingly restrictive, actually empowers developers. By defining types themselves, usually using a struct call, developers can catch potential errors at compile time. The compiler will check whether all function calls respect the defined parameter type. If not, the compiler will refuse to compile. This means, for example, that when one establishes a function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct HelloMessage {
    message: String
}
fn print_hello(hello: HelloMessage) {
    println!("{}", hello.message)
}
fn main () {
    print_hello(HelloMessage {message: String::from("Lep pozdrav") });
    // The following line will not compile, because "Zdravo" is not HelloMessage type
    // print_hello("Zdravo");
}

On the other hand, Dynamically typed languages do not require defining the type of a parameter. For example, in Python, you could write the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Message:
    def __init__(self, message):
        self.message = message

def print_hello(message):
    print(message)

def main():
    print_hello("živijo")
    # Displays: živijo
    print_hello(Message("hello"))
    # Displays: <__main__.Message object at 0x7f878f7ffdd0>

You see that the results were different, but the function did run without an Error. This is only sometimes the case. For example, if Message had another property defined and then we tried to access it, it would raise an exception (AttributeError). This trivial example is obvious, but when you try to figure out why a function is not acting as you would think, it becomes extremely annoying.

Benefit Dynamic Typing

The first obvious benefit of dynamic typing is that the code is much less verbose because you don’t need to explicitly define the types. However, the programmers are encouraged to use type hints, so the resulting verbosity of the code is similar to the statically typed language.

Another benefit of dynamic typing is the ease of passing collection types (arrays, lists, and especially dictionaries), which store different types, to a function. This is doable in statically typed languages but requires more effort on the developer’s part.

Drawbacks of Dynamic Typing

In statically typed languages, errors due to wrong types are caught during compilation, ensuring a robust and predictable system. This is in contrast to dynamically typed languages, where such errors can lead to unpredictable function outputs.

Furthermore, the type hints are encouraged but not enforced. Any code where the type of an argument is not immediately prominent is inherently less readable. Imagine deducting a variable in a method that calls for some attribute defined in some external library, which was not even imported.

For more comparisons of the two, visit the following links links: stackoverflow_1, stackoverflow_2.

Adding types to the Python

Suppose you use Python on a regular basis but want to overcome the issues created by the dynamically typed nature of it. In that case, you can try a few approaches to mitigate the possible type errors. Before I go into the details, I want to mention our sponsor, ~ type Hinting~. With just a few keyboard strokes, you can let future you and the language server protocol know what types you should pass into the function or method. Type Hinting seamlessly integrates with a built-in library typing, which you can use to define your types precisely:

1
2
3
4
5
6
from typing import List, Tuple, Union

def some_function(
    x: int, y: str, z: List[Tuple[Union[int,float]]]
    ) -> Union[int, None]:
    pass

Runtime type checking

When you have your types defined, you can start to check the types you are passing to a specific function. You can do it during runtime, leading to some computational overhead. Some solutions can help with type-checking.

Pydantic

Pydantic is an external library primarily used for validation. Its core logic is implemented in Rust, leading to fast data validation. Generally, pydantic is used for serialization in networked applications, but it also contains a neat decorator @validate_call:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pydantic import validate_call, ValidationError
from typing import Union

@validate_call
def greet_user(user_name: str, user_surname: Union[str, None]=None):
    if user_surname is None:
        return f"Pozdravljen {user_name}"
    return f"Pozdravljen {user_name} {user_surname}"

print(greet_user("John")) # Correctly prints the message
print(greet_user("John", "Type")) # Correctly prints the message
print(greet_user(1)) # Throws an ValidationError because we try to pass a wrong type

Pydantic allows for validation of complex and nested types. The logic is quite complex; thus, a less complex solution can be used in simple cases.

Custom Type Enforcing

Pydantic can be too heavy in some situations for the specific use case. In those situations, you can write a custom type enforcing decorator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def strict_types(func):
    def wrapper(*args, **kwargs):
        annotations = func.__annotations__
        print(annotations)
        for name, value in zip(annotations.keys(), args):
            if not isinstance(value, annotations[name]):
                raise TypeError(f"Argument `{name}` must be of type {annotations[name]}")
        print(annotations)
        return func(*args, **kwargs)
    return wrapper

@strict_types
def greet(name:str, age:int):
    return f"Pozdravjen {name}, start si {age} let."

greet("Alojzij", 25) # Returns the string correctly
greet("Alojzij", "petindvajset") # Raises a TypeError

To avoid defining your own decorator, you can also use type-enforced library. Simply install it with pip import it and decorate:

1
2
3
4
5
6
7
8
import type_enforced

@type_enforced.Enforcer(enabled=True)
def greet(name: str, age: int):
    return f"Pozdravljen {name}, star si {age}"

greet("Alojzij", 25) # Returns the string correctly
greet("Alojzij", "petindvajset") # Raises a TypeError

Static Type Analysis

You can use mypy for static type checking to avoid runtime type checking. You use it similarly to the testing suite, entailing running the mypy process once before you use the code. This means that you can include it in your staging workflow. To use mypy, you first need to install it with pip.

1
2
3
4
5
def greet(name: str) -> str:
    return f"Hej, {name}!"

print(greet("Alojzij"))
print(greet(1)) # Will be detected by the mypy

To perform the analysis, run the mypy command on the file you want to check.

1
2
source ~/.virtualenvs/test_env/bin/activate # Activate python env to get access to mypy
mypy ~/tmp/test_script.py

Automatic Type Annotations

Sometimes, someone has written a lot of code without type hints, and you want an easy way of assigning the types. In this case, you can use a tool named MonkeyType. To generate the type, you can then use the monkeytype command on your source code.

For example if you have a module greeting.py

1
2
def greet(name):
    return f"Pozdravljen {name}"

and then in another module, say main.py, you import it and use the function:

1
2
3
4
from greetings import greet

greet("Alojzij")
greet(1)

Now you can execute:

1
2
3
source ~/.virtualenvs/test_env/bin/activate # Activate python env to get access to mypy
cd ~/tmp
monkeytype run main.py

After run is completed, you can inspect the types:

1
2
3
source ~/.virtualenvs/test_env/bin/activate # Activate python env to get access to mypy
cd ~/tmp
monkeytype stub greetings

Which will generate the output:

1
2
3
4
from typing import Union


def greet(name: Union[int, str]) -> str: ...

As you can see it picked up also on the integer type, which we used in the main.py. Let’s leave it like that and apply the type hints:

1
2
3
source ~/.virtualenvs/test_env/bin/activate # Activate python env to get access to mypy
cd ~/tmp
monkeytype apply greetings

Now our source code of greetings.py was updated with the type hints:

1
2
3
4
from typing import Union

def greet(name: Union[str, int]) -> str:
    return f"Pozdravljen {name}"

Because of the fact that this changes your source code, it is advised that you use git to be able to reverse if incorrect type hints are applied.

Conclusions

We have covered techniques that make Python more resilient to type-related errors. With this approach, you can avoid these errors and make debugging experience more manageable.