Algebraic Data Types in Python
By: Gideon Caller
Tags: Python, Adt, Quality Software, Functional Programming, Programming

An Algebraic Data Type (or ADT in short) is a composite type. It enables us to model structures in a comprehensive way that covers every possibility. This makes our systems less error-prone and removes the cognitive mental load that comes when dealing with impossible states.

Motivation

Programmers who work in statically typed languages that have pattern matching are most likely using ADTs in their day to day work. If you’re not one of them, why should you care? I’ve decided to write about ADTs because:

Applicability — I was curious about the applicability of ADTs in dynamic languages like Python.

— I was curious about the applicability of ADTs in dynamic languages like Python. System understanding — categorizing certain parts of a problem in terms of ADTs leads to a more structured (and deeper) understanding of how our systems behave.

— categorizing certain parts of a problem in terms of ADTs leads to a more structured (and deeper) understanding of how our systems behave. Explicit design — Once we model parts of our system as ADTs we can embed certain architectural decisions into our design.

Defining ADTs

To be honest, I think there are already a bunch of good resources out there that define ADTs way better than I can. The reason I’ve decided to add this section is to make this post a complete reference (and not require us to jump from one resource to another).

Informally, ADTs:

Are a way to declare concrete, recursive and abstract structures.

Define which values and what variations are possible for these structures.

Are a composition of other types (which we expand on next)

ADTs are a composition of these types: Product and Sum Types. Product types define which values exist in the type definition while Sum types define which variations are legal for an ADT.

The last paragraph may have been a bit theoretical so let’s imagine a system that supports two kinds of users: authenticated and anonymous users. An authenticated user has an ID, an email, and a password. An anonymous user only has a name. Let’s represent these definitions with an ADT (I’m also using Scala for the Scala developers among us).

A Scala implementation:

A Python implementation:

The examples involve 3 types. AuthenticatedUser , AnonymousUser and User . AuthenticatedUser and AnonymousUser are the Product Types while User is the Sum Type (which is why User is explicitly mentioned in the Python example)

Product Types

Define the fields that the structure has. AuthenticatedUser is a Product type because it has an ID, an email, and a password. AnonymousUser is a Product type because it has a single name value. Although not mentioned in the previous example a Product type can have 0 values (we'll look into an example of this later on).

Product types define how many possible variations of AuthenticatedUser and AnonymousUser can exist in the system. We often refer to the number of possible variations as an Arity.

Sum types are the more interesting type of the two and are meant to define what are all the valid variations of a type. In the previous example, User is a Sum type because it can either be an AuthenticatedUser or an AnonymousUser . In the User example, a User must be either anonymous or authenticated and nothing else.

We use the term Sum type to define how many possible variations of a type can exist in the system. In the User example, User has 2 possible variations: authenticated or anonymous (an Arity of 2).

ADTs Examples

Before we discuss the reasoning behind ADTs it’s probably better if we’ll look at a few examples:

ADTs are everywhere, the following example shows that ADTs can even represent primitive types (I find it interesting even if the example is kinda useless)

Option allows us to write functions that either return a value or return nothing. What’s nice about Options is that we can make the optional return value explicit via type annotations (it has other advantages but they’re irrelevant for now).

It’s possible to also model operations as structures

Events are also possible candidates for ADTs

We can even represent a closed set of possible states. The following example shows how we can use an ADT to model the possible states of a Circuit Breaker (we can easily add a half-open state if we need to)

We can also use ADTs in Javascript and in React

The Reasoning Behind ADTs

One of the main ideas behind ADTs is that code that operates on an input ADT can be a total function. It knows in advance which variations are possible and can return a value for each of these variations. This has many advantages like code that is testable, easy to reason about, and deterministic. We can also use it as part of a functional core.

One implication for pushing towards total functions is that we have to know in advance all the possible variations of the ADT. If we had incorrectly defined an ADT to be inherently open for extension it would impossible to guarantee that functions that operate on it will indeed be total functions. When we define an ADT we are explicitly stating that our structure is inherently closed and we don’t expect it to frequently change (and in some cases never change).

There’s a big difference between “never change” and “don’t often expect it to change”. ADTs that are fundamentally closed like Option and boolean will likely never change. However, some ADTs will likely need to change sometime in the future (hopefully not often though). When this happens we want to have a fail-fast mechanism that prevents errors from creeping throughout the system.

Data Types & Operations Placement

When coming from an object-oriented background it may be tempting to place operations (or behavior) on the ADT and not separate them. There are, as always, tradeoffs for both options. This is actually a well-known problem called The Expression Problem. Uncle Bob also discusses this topic in one of his blog posts.

Uncle Bob summarizes the problem quite well IMO:

Adding new functions to a set of classes is hard, you have to change each class. Adding new functions to a set of data structures is easy, you just add the function, nothing else changes. Adding new types to a set of classes is easy, you just add the new class. Adding new types to a set of data structures is hard, you have to change each function. Uncle Bob Objects & Data structures

We can apply Uncle Bob’s guidelines and conclude that since ADTs are inherently closed we should aim to separate the operations from the ADTs.

Operating on ADTs

Going back to our Expression example (not to be confused with the Expression Problem), when using Python (or a similar language) the simplest way to operate on Expression will be to use some form of type checking:

In my opinion, the real concern here is for our system’s safety. Although we’re not expecting new types of Expression , it's possible that requirements will change (as they always do) and we will need to add a new Expression type (or more). When this happens, we will need to fix every code section that operates on Expression . This is very error-prone (in fact, when I initially wrote this example I forgot Divide and when I added it, I forgot to update the isinstance checks to match the new variation 🤦‍♂️)

If isinstance is not a good strategy then what are our options? It turns out Python has an ADT library that takes an interesting approach.

The Python ADT Library

This library tries to somewhat mimic pattern matching in Python by generating a match method for each ADT. Let's start by using it to generate an Option ADT.

Let’s go back to our Expression problem. This is how we would represent Expression with the ADT library and process it:

match achieves several interesting things:

In order to use the wrapped value, we are forced to deal with all the possible outcomes ( SOME and NONE or the different Expression Sum types) which means that functions that operate on the ADT ( parse_name or evaluate ) are total functions. The library automatically generates the match method. It verifies that we have a function to handle all the possible variations of the ADT. In our example, if we forget to write the lambda for SOME , NONE , LITERAL , MULTIPLY , etc, we will get an "Incomplete pattern match" error. An ADT can be recursive ( Expression ). Processing a recursive ADT may also require recursive processing.

These qualities cover our initial requirements. They allow us to write total functions that operate on the ADTs, and they fail-fast in case we do end up adding new variations of the type.

Summary

ADTs help us to create readable code that is predictable and is also easier to reason about. We use them to represent structures that are inherently closed but can seldom change. It’s for this reason that we tend to separate the operations from the actual structure and use total functions to operate on them. In the case, a new variation emerges we want to fail as soon as possible.

We’ve looked at how we can use Python’s ADT library to operate on ADTs. It’s possible to extend ADTs in such a way that they’re easily composable and even enforce certain constraints on the way we process them (constraints liberate, remember?). Maybe these are subjects for a future post?

In my opinion, the real benefit of thinking in terms of ADTs is that it truly improves the design of the system, it makes us think about which parts of our system are more likely to change than others and it encodes this understanding in the code (regardless of the language we’re using).

I’d love to get some feedback, improvement suggestions, and ideas for future posts. Feel free to reach out.

Further Reading