Much Ado About None: Sentinel Values in Python

2 days ago (florianwilhelm.info)

So much trouble to avoid introducing the optional monad.

  • Python’s type system has union types (including Option), and using them is pretty ergonomic. As TFA points out the problem is that `None` gets contextually reused in all kinds of places where a proper sentinel type is more appropriate.

    • Pretty sure Python's union types don't nest, though. If you could nest optionals then I feel that reduces the need for separate sentinel types since e.g., you can distinguish between "value not present" (None) and "null value present" (Some(None))

      11 replies →

  • I’m curious what that would practically look like in Python.

    I bet this would be a good use of vibe coding. Experiment with what-ifs. I can imagine being able to “show not tell” and reduce the signal-to-noise of comment opinions.

    • Python makes it very easy to do these experiments without changing cpython itself. For example, a lot of the cases for the "not set" Sentinel can be handled by making it MyClass | None. Treat None as "not set". If you need a "no-op" then you can give MyClass a field or something which implies it does nothing. And then override the bool dunder method to make it return false if the no op field is set to true. If you really want, you can have a classmethod which returns a "no op" instance of MyClass so people can easily call your func with MyClass.no_operation() when you want no op, and null when its not actually set.

      Sentinels just help you wrap that up nicely and for more general use cases, but theres value in stepping back and thinking about where the unset vs no op case matters.

    • It would look like a very different language that would be thoroughly incompatible with the existing ecosystem.

When I was writing a bunch of Lua, I was a big fan of having a None sentinel value (Lua uses 'nil' for representing absence) and it was useful for a lot of things similar to Pydantic's MISSING.

This distinction is something that I think is actually handy about JavaScript, and distinguishing between 'null' and 'undefined' is sometimes useful for similar reasons.

  • I feel like faster exceptions would be a better solution for this sort of thing. Leave functions returning the happy path with excepted values. Exceptional things, like missing values being handled at the level where of abstraction that actually cares to catch it:

"A simple cache returns None both for a missing key and for a key that maps to None."

No, it returns KeyError. Or at least it should.

  • The author’s example chose to use the syntax that returns None on missing. If they wanted clarity the standard API does raise.

      cache = {“key”: “value”, “some input”: None}
      cache.get(“missing_key”)
    

    Vs

      cache[“missing_key”]
    

    There are better examples that could have been highlighted.

  • it’s maybe less “pythonic” but at $dayjob we’ve very actively moved away from exceptions for things in favor of things that are statically type checked. too many things that become runtime bugs instead of type checking bugs.

    with match now being available, we even return explicit error objects and the ergonomics are pretty good (reminds me of rust).

    • How are these two things related? If you don't use exceptions, you just have to implement them yourself by manually bubbling up the error, all the way up the stack, rather than using the dedicated side channel to do so.

      The problem I see is there's no way to enforce the check that you actually handled/bubbled up the error. With exceptions, you just crash if you don't handle it, which is much preferred if determinism is the goal. Is there some way to use the type system to do this? Maybe incoming are UnhandledResult outgoing are some HandledResult or something like that?

      1 reply →

  • Perhaps the author has had their fair share of run-ins with libraries that didn't think their API through like that. But yes, they should.

  • > No, it returns KeyError. Or at least it should.

    Surely you meant _raises_? Or did you literally mean to return an exception class?

Here is the sample code from the article:

    > MISSING = sentinel("MISSING")

    > def update_email(user_id: int, email: str | None | MISSING = MISSING) -> None:
    >     if email is MISSING:
    >         return  # nothing to update
    >     elif email is None:
    >         db.delete_email(user_id)
    >     else:
    >         db.set_email(user_id, email.lower())  # narrowed to str

I see this style frequently from long time Python programmers. The language is so dynamic that people begin to think this is a good way to program. Hot take: It is not. Why not two different functions called delete_email() and set_email()? That would be more clear.

  • I think this sort of pattern makes more sense when the fn is update_user and allows the caller to mutate multiple fields at once.