The Association of Mad Scientists

Python Type-Hints and Strictness

I was taught programming in Java, which, relatively to Python, has a lot of boilerplate. So when I began writing in Python more frequently, it felt freeing, as though I could more effectively and quickly get my thoughts down and put them to use. This is especially true for small, single-purpose scripts, like the ones I recently wrote for my i3blocks configuration. The problem is, this same freedom can lead to confusion and bugs when writing larger projects. One such feature that can create confusion is Python’s strong, but “duck” typed variables. Consider a method I recently wrote:

@property
def current_volume(self):
    """Retrieve the current volume level."""
    return mixer.music.get_volume()

This is for retrieving the current volume level from an audio playback API. Now, when I’m interpreting that, I might use something like this:

print("The current volume is: %s" % player.current_volume)

However, what if the API returns an integer? We don’t know, because it’s not specified explicitly in the code, and the documentation might lack this information as well.

Recently, Python has tried to help this problem by creating a standardized way of documenting this information and embedding it straight into your code. They call it type hints. As soon as I found out about this feature, I started sprinkling it into my scripts here and there. I didn’t use it all the time, but sometimes it was really obvious what the type would need to be, so I had a convenient way to annotate this. The above example annotated with type hints looks like this:

@property
def current_volume(self) -> int:
    """Retrieve the current volume level."""
    return mixer.music.get_volume()

There’s just one problem — type hints are in no way enforced, at all. Not at runtime, not at compile-time (because there isn’t one), no verification or warning that the received value is actually what you expect. I immediately began running into this problem. Stuff that I expected to be one type would cause problems down the line becaues it was actually something totally incompatible with the expected type.

To return to our previous example, I initially expected the API I was working with to return the volume as an integer between 1 and 100. Why did I think that? Mainly because I figured any more than that amount of granularity would be silly, but I also knew it could be anything. My first assumption did not turn out to be correct, and my volume-up method didn’t work either.

def volume_up(self):
    """Request that the audio volume be increased by 5%"""
    log.debug(
        "Volume requested to be turned up. Current volume %s"
        % str(self.current_volume)
    )
    mixer.music.set_volume(self.current_volume + 5)

The actual return value from self.current_volume was a float between 0 and 1, which did not take to being incremented by 5. If type hints were enforced at all, this would have thrown an error or warning of some kind whenever self.current_volume returned a value that wasn’t an int. Instead, the float was happily passed along, and the volume up function simply silently failed.

There is a solution to this, I’m not just bitching. And the solution is awesome. The corrected method with enforcement enabled requires a third party library, strict-hint, and an additional import (from strict_hint import strict). The final method looks like this:

@strict
@property
def current_volume(self) -> float:
    """Retrieve the current volume level."""
    return mixer.music.get_volume()

That’s it, just add @strict, and if the method tries to return a string, or an integer, it’ll throw an exception and let you know.

The nicest thing about this isn’t that it enables strict typing. If I wanted pure strict typing, I could write in Kotlin, Java, even C++, and be just as effective. What I’m excited about here is that it’s optional. Sometimes, you don’t want to assert what type you’re going to receive or return, and sometimes, it’s just easier not to. And that’s okay, you can still code as fast as your little fingers will allow, and toss these in later, or not at all, and Python will run your script regardless.