1

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.

Jiehong
  • 786
  • 1
  • 7
  • 16

0 Answers0