I've been exploring how to use a monad/applicative functor for data validation in Python from scratch.
In particular, I have created a new Validation
class allowing to represent the result of a validation: Success
and Error
. It's basically a nominal Result
type, but not quite:
from __future__ import annotations
from typing import List, TypeVar, Generic, Callable, Optional
A = TypeVar('A')
B = TypeVar('B')
class Validation(Generic[A]):
def __init__(self, value: Optional[A], errors: List[str]) -> None:
self.value = value
self.errors = errors
def is_success(self):
raise NotImplementedError('')
def is_error(self):
return not self.is_success()
def map(self, function: Callable[[A], B]) -> Validation[B]:
raise NotImplementedError('')
def bind(self, function: Callable[[A], Validation[B]]) -> Validation[B]:
raise NotImplementedError('')
class Error(Validation[A]):
def __init__(self, errors: List[str]) -> None:
super().__init__(None, errors)
def is_success(self):
return False
def map(self, function: Callable[[A], B]) -> Validation[B]:
return self
def bind(self, function: Callable[[A], Validation[B]]) -> Validation[B]:
return self
class Success(Validation[A]):
def __init__(self, value: A) -> None:
super().__init__(value, [])
def is_success(self):
return True
def map(self, function: Callable[[A], B]) -> Validation[B]:
return function(self.value)
def bind(self, function: Callable[[A], Validation[B]]) -> Validation[B]:
return function(self.value)
This works just fine, and it will stop at the first error encountered:
def check_1(data: str) -> Validation[str]:
print(f"data 1: {data}")
return Success(data)
def check_2(data: str) -> Validation[str]:
print(f"data 2: {data}")
return Error(['unable to validate with check 2'])
def check_3(data: str) -> Validation[str]:
print(f"data 3: {data}")
return Error(['unable to validate with check 3'])
if __name__ == "__main__":
print("Stopping at the first error (aka. monad's bind):")
checks = Success('input to check') \
.bind(check_1) \
.bind(check_2) \
.bind(check_3)
assert checks.value is None
assert checks.errors == ['unable to validate with check 2']
Why should bind
be defined as such?
Nothing stops me from defining a new bind2
, which does not stop at the first error encountered, but instead tries all of them until the end:
class Validation(Generic[A]):
# (...)
def bind2(self, function: Callable[[A], Validation[B]]) -> Validation[B]:
raise NotImplementedError('')
class Error(Validation[A]):
# (...)
def bind2(self, function: Callable[[A], Validation[B]]) -> Validation[B]:
return self
class Success(Validation[A]):
# (...)
def bind2(self, function: Callable[[A], Validation[B]]) -> Validation[B]:
application = function(self.value)
if application.is_success():
return application
else:
self.errors += application.errors
return self
This works just as expected:
if __name__ == "__main__":
print("Showing all errors without stopping at the first one:")
checks = Success('input to check') \
.bind2(check_1) \
.bind2(check_2) \
.bind2(check_3)
assert checks.value == 'input to check'
assert checks.errors == ['unable to validate with check 2', 'unable to validate with check 3']
Given that, why not have a bind2
not short-circuiting?
I've read that applicative functors' apply
method should be used instead here, but I don't really see how this could help, because its signature is def apply(self, function: Validation[Callable[[A], B]]) -> Validation[B]:
(if I'm not mistaken) which does not accept any of my check_x
functions signatures.