## TIL #083 β sentinel value for default arguments

Today I learned how to create a sentinel value to use as a default argument in a way that respects Python typing.

# Sentinel value for default arguments

## Context

Today at work I had a problem. I was working on a progress bar that Textual has:

The ProgressBar class has a method update that you can use to update the status of the progress bar. The method update looked roughly like this:

class ProgressBar(...):
...

def update(
self,
total: float | None = None,
progress: float | None = None,
advance: float | None = None,
) -> None:
"""Update the status of the progress bar."""
if total is not None:
self.total = total
if progress is not None:
self.progress = progress

By calling update, you can change the total βsizeβ of the progress bar (total), you can change how far along you are with the progress (progress), and you can increment the current progress by a given amount (advance).

For example, if you create a progress bar pb and then call pb.update(total=100, progress=30, advance=7), your progress bar ends up at 37% completion and it would look something like this:

What's the problem, then?

## Using another default value instead of None

The default value for total is None, but the truth is that I also want to be able to set total to None, because that turns the progress bar into its indeterminate/pulsing state. So, how can I do this?

The answer is obvious: just use another default value!

That's right, but I can't use any random default value, because then the typing will look odd! For example, I could use the string "default", and modify the method to look like this:

class ProgressBar(...):
...

def update(
self,
total: float | str | None = "default",
progress: float | None = None,
advance: float | None = None,
) -> None:
"""Update the status of the progress bar."""
if not isinstance(total, str):
self.total = total
if progress is not None:
self.progress = progress

The problem with this, I argue, is that it looks really odd to have total accept values of the type str, when it really doesn't, it's just so that the typing matches the default value given...

In other words, if I just had

...
total: float | None = "default",
...

then the typing would be wrong. And I want to have the code typed correctly, if possible.

So, how do we solve this?

## Using object as the default value

My next thought went to using object as the default value. So, something like this:

_sentinel = object()

class ProgressBar(...):
...

def update(
self,
total: float | None | object = _sentinel,
progress: float | object = _sentinel,
advance: float | object = _sentinel,
) -> None:
"""Update the status of the progress bar."""
if total is not _sentinel:
self.total = total
if progress is not _sentinel:
self.progress = progress

This looked promising, but there is another typing issue with this approach... And that's that anything you pass in to update will satisfy the type-checker!

For example, if you write something like

the type-checker will not complain, because all the parameters accept the type object, and everything in Python is an instance of object:

>>> isinstance("billions", object)
True
>>> isinstance((1, 2), object)
True
>>> isinstance(True, object)
True

So, object isn't quite the answer we are looking for.

I thought some more and I ended up going down a rabbit hole that involved TypeVar and TypeGuard... And I got it to work with a bit of help! But it was way more complicated than the solution I'm about to present next.

## Create a sentinel class

A simple solution that satisfies the type-checker involves creating an empty sentinel class! Then, you create an instance of that class and use it as the default value for the parameters. Something like this:

class UnusedParameter:
pass

_sentinel = UnusedParameter()

class ProgressBar(...):
...

def update(
self,
total: float | None | UnusedParameter = _sentinel,
progress: float | UnusedParameter = _sentinel,
advance: float | UnusedParameter = _sentinel,
) -> None:
"""Update the status of the progress bar."""
if not isinstance(total, UnusedParameter):
self.total = total
if not isinstance(progress, UnusedParameter):
self.progress = progress