These are five implementations I’ve come up with for an equivalent for Haskell’s Maybe
in Python. It’s not called Optional
because typing.Optional
already exists.
Here’s a simple monolithic class. The benefit of this one is that it can be inherited from:
from __future__ import annotations
from typing import Callable, Generic, Optional, TypeVar, cast, overload, Any
T = TypeVar("T")
U = TypeVar("U")
C = TypeVar("C", bound="Maybe[Any]")
class MissingValueError(ValueError):
"Raised to indicate a potentially missing value was missing."
pass
class Maybe(Generic[T]):
present: bool
value: Optional[T]
@overload
def __init__(self, /) -> None:
...
@overload
def __init__(self, value: T, /) -> None:
...
def __init__(self, /, *args: T) -> None:
if len(args) == 0:
self.present = False
self.value = None
else:
self.present = True
self.value = args[0]
@classmethod
def Just(cls: type[C], value: T, /) -> C:
return cls(value)
@classmethod
def Nothing(cls: type[C]) -> C:
return cls()
def assume_present(self) -> T:
if not self.present:
raise MissingValueError()
return cast(T, self.value)
def map(self: Maybe[T], f: Callable[[T], U], /) -> Maybe[U]:
cls = type(self)
if not self.present:
return cls()
else:
return cls(f(cast(T, self.value)))
def flatmap(self: Maybe[T], f: Callable[[T], Maybe[U]], /) -> Maybe[U]:
cls = type(self)
if not self.present:
return cls()
else:
maybe2 = f(cast(T, self.value))
if not maybe2.present:
return cls()
else:
return cls(cast(U, maybe2.value))
def join(self: Maybe[Maybe[T]], /) -> Maybe[T]:
cls = cast(type[Maybe[T]], type(self))
if not self.present:
return cls()
elif not cast(Maybe[T], self.value).present:
return cls()
else:
return cls(cast(T, cast(Maybe[T], self.value).value))
@classmethod
def from_optional(cls: type[C], value: Optional[T], /) -> C:
if value is None:
return cls()
else:
return cls(value)
@classmethod
def with_bool(cls: type[C], /, present: bool, value: T) -> C:
if present:
return cls(value)
else:
return cls()
Here’s one where Maybe
is an abstract base class from which Just
and Nothing
inherit.
It supports pattern matching and checking via .present
:
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Callable, Generic, Optional, TypeVar, cast
T = TypeVar("T", covariant=True)
G = TypeVar("G")
U = TypeVar("U")
class Maybe(ABC, Generic[T]):
present: bool
value: Optional[T]
@abstractmethod
def assume_present(self) -> T:
pass
@abstractmethod
def map(self: Maybe[G], f: Callable[[G], U], /) -> Maybe[U]:
pass
@abstractmethod
def flatmap(self: Maybe[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
pass
@abstractmethod
def join(self: Maybe[Maybe[G]]) -> Maybe[G]:
pass
@staticmethod
def from_optional(value: Optional[G], /) -> Maybe[G]:
if value is None:
return Nothing[G]()
else:
return Just[G](value)
@staticmethod
def with_bool(present: bool, value: G) -> Maybe[G]:
if present:
return Just[G](value)
else:
return Nothing[G]()
class Just(Maybe[T]):
def __init__(self: Just[T], value: T) -> None:
self.present = True
self.value = value
def assume_present(self: Just[G]) -> G:
return cast(G, self.value)
def map(self: Just[G], f: Callable[[G], U], /) -> Just[U]:
return Just[U](f(cast(G, self.value)))
def flatmap(self: Just[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
return f(cast(G, self.value))
def join(self: Just[Maybe[G]]) -> Maybe[G]:
return cast(Maybe[G], self.value)
__match_args__ = ("value",)
class MissingValueError(ValueError):
"Raised to indicate a potentially missing value was missing."
pass
class Nothing(Maybe[T]):
def __init__(self: Nothing[T]) -> None:
self.present = False
self.value = None
def assume_present(self: Nothing[G]) -> G:
raise MissingValueError()
def map(self: Nothing[G], f: Callable[[G], U], /) -> Nothing[U]:
return Nothing[U]()
def flatmap(self: Nothing[G], f: Callable[[G], Maybe[U]], /) -> Nothing[U]:
return Nothing[U]()
def join(self: Nothing[Maybe[G]]) -> Nothing[G]:
return Nothing[G]()
__match_args__ = ()
Here’s one that takes advantage of Python’s type system. It supports pattern matching and checking via isinstance(
.
from dataclasses import dataclass
from typing import Callable, TypeAlias, TypeVar, Generic, Optional
T = TypeVar("T", covariant=True)
G = TypeVar("G")
U = TypeVar("U")
@dataclass
class Nothing(Generic[T]):
pass
@dataclass
class Just(Generic[T]):
value: T
Maybe: TypeAlias = Nothing | Just[T]
def maybe_from_optional(value: Optional[G], /) -> Maybe[G]:
if value is None:
return Nothing[G]()
else:
return Just[G](value)
def maybe_with_bool(present: bool, value: G) -> Maybe[G]:
if present:
return Just[G](value)
else:
return Nothing[G]()
class MissingValueError(ValueError):
"Raised to indicate a potentially missing value was missing."
pass
def assume_present(maybe: Maybe[G], /) -> G:
if isinstance(maybe, Nothing):
raise MissingValueError
return maybe.value
def map_maybe(f: Callable[[G], U], maybe: Maybe[G], /) -> Maybe[U]:
if isinstance(maybe, Nothing):
return Nothing[U]()
else:
return Just[U](f(maybe.value))
def flatmap_maybe(f: Callable[[G], Maybe[U]], maybe: Maybe[G], /) -> Maybe[U]:
if isinstance(maybe, Nothing):
return Nothing[U]()
else:
return f(maybe.value)
Here’s one where Maybe
is a protocol that Just
and Nothing
implement.
Again, checking is done via pattern matching or isinstance(
:
from __future__ import annotations
from typing import Callable, Optional, TypeVar, cast, Protocol
T = TypeVar("T", covariant=True)
G = TypeVar("G")
U = TypeVar("U")
class Maybe(Protocol[T]):
def assume_present(self) -> T:
...
def map(self: Maybe[G], f: Callable[[G], U], /) -> Maybe[U]:
...
def flatmap(self: Maybe[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
...
def join(self: Maybe[Maybe[G]]) -> Maybe[G]:
...
class Just(Maybe[T]):
value: T
def __init__(self: Just[T], value: T) -> None:
self.value = value
def assume_present(self: Just[G]) -> G:
return self.value
def map(self: Just[G], f: Callable[[G], U], /) -> Just[U]:
return Just[U](f(self.value))
def flatmap(self: Just[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
return f(self.value)
def join(self: Just[Maybe[G]]) -> Maybe[G]:
return self.value
__match_args__ = ("value",)
class MissingValueError(ValueError):
"Raised to indicate a potentially missing value was missing."
pass
class Nothing(Maybe[T]):
def __init__(self: Nothing[T]) -> None:
self.present = False
self.value = None
def assume_present(self: Nothing[G]) -> G:
raise MissingValueError()
def map(self: Nothing[G], f: Callable[[G], U], /) -> Nothing[U]:
return Nothing[U]()
def flatmap(self: Nothing[G], f: Callable[[G], Maybe[U]], /) -> Nothing[U]:
return Nothing[U]()
def join(self: Nothing[Maybe[G]]) -> Nothing[G]:
return Nothing[G]()
__match_args__ = ()
def maybe_from_optional(value: Optional[G], /) -> Maybe[G]:
if value is None:
return Nothing[G]()
else:
return Just[G](value)
def maybe_with_bool(present: bool, value: G) -> Maybe[G]:
if present:
return Just[G](value)
else:
return Nothing[G]()
And finally, here’s a variant of the first monolithic one that uses a sentinel object instead of a present
property:
from __future__ import annotations
from typing import Callable, Generic, Optional, TypeVar, cast, overload, Any
T = TypeVar("T")
U = TypeVar("U")
C = TypeVar("C", bound="Maybe[Any]")
class MissingValueError(ValueError):
"Raised to indicate a potentially missing value was missing."
pass
class Maybe(Generic[T]):
sentinel = object()
value: T | object
@overload
def __init__(self, /) -> None:
...
@overload
def __init__(self, value: T, /) -> None:
...
def __init__(self, /, *args: T) -> None:
if len(args) == 0:
self.value = Maybe.sentinel
else:
self.value = args[0]
@classmethod
def Just(cls: type[C], value: T, /) -> C:
return cls(value)
@classmethod
def Nothing(cls: type[C]) -> C:
return cls()
def assume_present(self) -> T:
cls = type(self)
if self.value is cls.sentinel:
raise MissingValueError()
return cast(T, self.value)
def map(self: Maybe[T], f: Callable[[T], U], /) -> Maybe[U]:
cls = type(self)
if self.value is cls.sentinel:
return cls()
else:
return cls(f(cast(T, self.value)))
def flatmap(self: Maybe[T], f: Callable[[T], Maybe[U]], /) -> Maybe[U]:
cls = type(self)
if self.value is cls.sentinel:
return cls()
else:
maybe2 = f(cast(T, self.value))
if maybe2.value is cls.sentinel:
return cls()
else:
return cls(cast(U, maybe2.value))
def join(self: Maybe[Maybe[T]], /) -> Maybe[T]:
cls = cast(type[Maybe[T]], type(self))
if self.value is cls.sentinel:
return cls()
elif cast(Maybe[T], self.value).value is cls().sentinel:
return cls()
else:
return cls(cast(T, cast(Maybe[T], self.value).value))
@classmethod
def from_optional(cls: type[C], value: Optional[T], /) -> C:
if value is None:
return cls()
else:
return cls(value)
@classmethod
def with_bool(cls: type[C], /, present: bool, value: T) -> C:
if present:
return cls(value)
else:
return cls()
Each of these function a bit differently, but they all broadly achieve the same thing—a Maybe
type which can be a missing value or a present value, clearly distinguished. The monolithic ones have the benefit of being inheritable, although their typing is lacking as it does not represent the subclasses very well, as I know no way to accurately type methods of inheritable generic classes that change the parameter.
My questions: Which of these do you think is the best, and do you think there are any clear improvements that can be made to each one?
As I have been asked for a use-case for this, I will present the catalyst for me trying to implement this:
I was trying to generate random Signature
objects using the Hypothesis library. Signature
objects are composed of a list of Parameter
objects subject to a number of conditions. To implement the generation, I ended up using a @composite
search strategy that draws random lists of each of the parameters needed to construct a Parameter
object. As part of this, I wanted to generate default values, and needed to distinguish between the absence of a default value for a parameter and the presence of one that is None
. Unfortunately, generating Parameter.empty()
, which is used internally by Parameter
, as one of the parameters to Parameter
didn’t work.
In the end, I went with generating a one-off sentinel object and checking identity on it, but I find this solution to be less than satisfying. Because of this, I decided to try and implement a Maybe
type in Python.