Statically-typed validation in Python with Mypy

July 21, 2015

Let's implement Data.Validation in Python, using Mypy to annotate and enforce types. We'll start with an abstract Validation class that defines map and ap, and implement Valid and Invalid representations of both.

validation.py:

Grab a few fields from the Mypy typing module:

from typing import TypeVar, Generic, List, Callable, abstractmethod, cast

Declare a couple of variables:

A = TypeVar('A')
B = TypeVar('B')

Define the abstract Validation class:

class Validation(Generic[A]):

  @abstractmethod
  def valid(self) -> bool: pass

  @abstractmethod
  def map(self, f: Callable[[A], B]) -> 'Validation[B]': pass

  @abstractmethod
  def ap(self, a: 'Validation[Callable[[A], B]]') -> 'Validation[B]': pass

  @abstractmethod
  def show(self) -> str: pass

Implement Valid, the valid representation of Validation:

class Valid(Validation):

  def __init__(self, value: A) -> None:
    self.value = value

  def valid(self) -> bool:
    return True

  def map(self, f: Callable[[A], B]) -> Validation[B]:
    return Valid(f(self.value))

  def ap(self, a: Validation[Callable[[A], B]]) -> Validation[B]:
    return a.map(lambda f: f(self.value))

  def show(self) -> str:
    return 'Valid(' + self.value.__str__() + ')'

Implement Inalid, the invalid representation of Validation:

class Invalid(Validation):

  def __init__(self, errors: List[str]) -> None:
    self.errors = errors

  def valid(self) -> bool:
    return False

  def map(self, f: Callable[[A], B]) -> Validation[B]:
    return self

  def ap(self, a: Validation[Callable[[A], B]]) -> Validation[B]:
    if (a.valid()):
      return self
    else:
      return Invalid(self.errors + cast(Invalid, a).errors)

  def show(self) -> str:
    return 'Invalid(' + self.errors.__str__() + ')'

Type-check it with Mypy:

$ mypy validation.py
$ echo $?
0

No errors -- it compiles! Let's take it for a spin.

demo.py:

Import a few prerequisites:

from typing import Callable
from validation import Valid, Invalid, Validation

Write a curried function for building a full name out of a first and last name:

def name(first: str) -> Callable[[str], str]:
  def k(last: str) -> str:
    return first + ' ' + last
  return k

Write a name-parsing function that can return a valid or invalid result:

def nonempty(name: str, x: str) -> Validation[str]:
  if not x:
    return Invalid([name + ' must not be empty'])
  else:
    return Valid(x)

Create some valid and invalid first and last names:

firstV = nonempty('first name', 'John');
firstI = nonempty('first name', '');

lastV = nonempty('last name', 'Doe');
lastI = nonempty('last name', '');

Put it all together:

nameV1 = lastV.ap(firstV.map(name))
print(nameV1.show()) #Valid(John Doe)

nameI1 = lastI.ap(firstV.map(name))
print(nameI1.show()) # Invalid(['last name must not be empty'])

nameI2 = lastV.ap(firstI.map(name))
print(nameI2.show()) # Invalid(['first name must not be empty'])

nameI3 = lastI.ap(firstI.map(name))
print(nameI3.show()) # Invalid(['last name must not be empty',
                     #          'first name must not be empty'])

Run it with Python:

$ python3 demo.py
Valid(John Doe)
Invalid(['last name must not be empty'])
Invalid(['first name must not be empty'])
Invalid(['last name must not be empty', 'first name must not be empty'])