Comment by aw1621107

15 hours ago

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))

Indeed. Option[Foo] really just means Foo | None - you can’t have an Option[Foo] at runtime. Fundamentally, Python’s static typing system describes a language that is a sort of mediocre match for Python.

Another fun one is List[Foo]. Runtime Python has lists but you can’t ask a list what type of list it is, and you can’t write isinstance(a, List[Foo]).

Can you explain or give an example of nested optional types? I don't quite see how that makes sense

  • The idea is that optional values are either some value or no value, but sometimes the inner "some value" bit can itself be an optional value (in Rust this would be Option<Option<T>>, in C++ it'd be std::optional<std::optional<T>>, in Python it'd hypothetically be Option[Option[T]], etc.). Such a situation gives you three possible states:

    - You have an inner value and the inner value is something non-null (an integer, a string, another optional, etc.)

    - You have an inner value and the inner value is null

    - You do not have an inner value

    The problem, from what I understand, is that Option[T] in Python is equivalent to the union type T | None, and union types are flattened (e.g., (T | U) | V is equivalent to T | U | V) and duplicates are ignored (e.g., T | U | T is equivalent to T | U). As a result, Option[Option[T]] is equivalent to (T | None) | None, which is equivalent to T | None | None, which is finally equivalent to T | None, which means you lose the distinction between "No inner value" and "Inner value is present but is None".

    As a more concrete (albeit somewhat contrived) example, say there's some API you can query for the owner of a particular document ID where the document ID may or may not refer to an actual document (e.g., maybe you got it from a cache and the document has since been deleted). One way to express this would be (using Rust syntax)

        fn get_owner(id: DocumentId) -> Option<Option<UserId>> { ... }
    

    So if you get None, the document does not exist; if you get Some(None), the document exists but it has no owner; and if you get Some(<id>) the document exists and it has an owner.

    (This can probably be done better by creating new enums, but it's an example so give me a bit of slack)

    A naive port of this signature to Python would be

        def get_owner(id: DocumentId) -> Option[Option[UserId]]: pass
    

    But to type checkers this is equivalent to

        def get_owner(id: DocumentId) -> UserId | None: pass
    

    So now you're in the pickle the OP describes: you can end up with a None, but you no longer knonw whether it means "no document" or "no owner". The Python solution is to basically create a different name for either the outer None (e.g., MISSING as in the OP) or the inner one (e.g., NO_OWNER or the like).

    • Most languages can do this if it's what you want. In Python it would be:

          class Some[T]:
              value: T
      
          type Option[T] = Some[T] | None
      
          def get_owner(id: DocumentId) -> Option[Option[UserId]]: ...
      
          match get_owner(document_id):
              case None:
                  ...
              case Some(None):
                  ...
              case Some(Some(x)):
                  ...

      1 reply →

    • I see, thanks. Though this feels like something that would always have a better alternative than returning Some(None). Probably a new enum, or in Python's case, exceptions (e.g. instead of returning a value if the document is missing, you'd raise DocumentMissingError or something)

    • This seems really silly to me. Why does get_owner need to differentiate between the document exists and maybe has an owner or the document doesn’t exist and thus has no owner. In both cases getting no owner should suffice? Why are you using get_owner to try and figure out if the document exists? Can you give an example where this distinction actually matters?

      8 replies →

  • Say you select nullable column into vec, Vec<Option<T>>. vec.get returns Option<Option<T>>, which distinguishes null from out of bounds

    I'd like nested Nullable in ClickHouse to use CoalescingMergeTree to support postgres unchanged TOAST values easily without getting mixed up on nulls

  • The answer is they don't in practical code but they come up in generic code, where you want to wrap something that is already returning an optional in your optional. In languages that do support optional[optional[whatever]], if you every actually write that you know you've messed up your API.

    • They absolutely do in practical non-generic code. A case I personally encounter regularly is json APIs which distinguish between a key being missing and one being set to null in a structure.

  • If you have a map, get(key) returns Option[ValueT]: None if the key is missing, Some(value) otherwise. If the value type itself is, say, Optional[String], your return type is Optional[Optional[String]]. Here None still always means the key is missing, while Some(None) means the value is just None.