We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.

Sum Types in Python: Using Enums, Unions, and Match

Boot.dev Team
Boot.dev TeamProgramming course authors and video producers

Last published

Table of Contents

Sum types are a staple of functional programming, but Python doesn't really support them. We have to use a workaround and invent our own little system and enforce it ourselves.

All the content from our Boot.dev courses are available for free here on the blog. This one is the "Sum Types" chapter of Learn Functional Programming in Python. If you want to try the far more immersive version of the course, do check it out!

What Is a Sum Type?

A "sum" type is the opposite of a "product" type. This Python object is an example of a product type:

man.studies_finance = True
man.has_trust_fund = False

The total number of combinations a man can have is 4, the product of 2 * 2. If we add a third attribute, perhaps a has_blue_eyes boolean, the total number of possibilities multiplies again, to 8!

As opposed to product types, which can have many (often infinite) combinations, sum types have a fixed number of possible values.

How Do You Fake Sum Types With Classes?

But let's pretend that we live in a world where there are really only three types of people that our program cares about:

  1. Dateable
  2. Undateable
  3. Maybe dateable

We can reduce the number of cases our code needs to handle by using a (fake Pythonic) sum type with only 3 possible types:

class Person:
    def __init__(self, name: str) -> None:
        self.name = name

class Dateable(Person):
    pass

class MaybeDateable(Person):
    pass

class Undateable(Person):
    pass

Then we can use the isinstance built-in function to check if a Person is an instance of one of the subclasses. It's a clunky way to represent sum types, but hey, it's Python.

def respond_to_text(guy_at_bar: Person) -> str:
    if isinstance(guy_at_bar, Dateable):
        return f"Hey {guy_at_bar.name}, I'd love to go out with you!"
    elif isinstance(guy_at_bar, MaybeDateable):
        return f"Hey {guy_at_bar.name}, I'm busy but let's hang out sometime later."
    elif isinstance(guy_at_bar, Undateable):
        return "Have you tried being rich?"
    else:
        raise ValueError("invalid person type")

If classes and objects are new to you, this pattern leans on subclassing and polymorphism.

How Do Union Types Help?

Simulating sum types with classes is better than nothing, but it's awkward. The type hints system in modern Python offers a more direct way of describing a value that may be one type or another. We can use what's called a union type:

def parse_document(doc_name: str, content: str) -> Parsed | ParseError:
    ...

The Parsed | ParseError annotation means, "This function returns either a Parsed value or a ParseError value." Crucially, the | ("or") operator lets us express that relationship without forcing both classes to inherit from the same parent class. Parsed and ParseError still need to be real types, but they don't need to belong to a shared class hierarchy.

A union type can list any number of possible types for a given value. One of the most common use cases is for optional values like str | None – i.e., a value that may be a string, or may be None if the string isn't available yet or couldn't be retrieved.

In functional programming, union types are used constantly to make "this or that" situations explicit: some value or none; a result from a function or an error.

Python is still ultimately a dynamically typed language. The union type, like other type hints, is meant to help developers and their tools (code editors, type checkers). It's not enforced at runtime. But being able to document the shape of your data makes it easier to write robust programs.

What Are Enums in Python?

Click to play video

If what you're trying to represent is a fixed set of values, you have another good option in Python's type system: enums.

Let's say we have a Color variable that we want to restrict to only three possible values:

  • RED
  • GREEN
  • BLUE

We could use a plain old str to represent these values, but that's annoying because we have to keep track of the "valid" values and defensively check for invalid ones all over our codebase. Instead, we can use an Enum:

from enum import Enum

Color = Enum("Color", ["RED", "GREEN", "BLUE"])
print(Color.RED)   # this works, prints 'Color.RED'
print(Color.TEAL)  # this raises an exception

There is also a manual class-based syntax:

from enum import Enum

class Color(Enum):
    RED = 1
    GREEN = 2
    BLUE = 3

print(Color.RED)   # this works, prints 'Color.RED'
print(Color.TEAL)  # this raises an exception

The class-based syntax is more verbose, but safer because it prevents ambiguity between the variable name and the enum name. With Color = Enum("Color", ...), the string "Color" sets the enum class name, while Color = assigns that class to a variable. While those names normally shouldn't be different, they can be.

Now Color is a sum type! At least, as close as we can get in Python. There are a few benefits:

  1. A Color can only be RED, GREEN, or BLUE. If you try to use Color.TEAL, Python raises an exception.
  2. There is a central place to see the "valid" values for a Color.
  3. Each Color has a "name" (e.g. RED) and an integer value (e.g. 1). The value can be useful if you need to store, compare, or serialize the enum in a specific way.

Why Doesn't Python Fully Support Sum Types?

Python doesn't enforce your types before your code runs, the way some statically typed languages do. That's why we need this line here to raise an Exception if a color is invalid:

def color_to_hex(color: Color) -> str:
    if color == Color.GREEN:
        return "#00FF00"
    elif color == Color.BLUE:
        return "#0000FF"
    elif color == Color.RED:
        return "#FF0000"
    # handle the case where the color is invalid
    raise Exception("unknown color")

In a language like Rust, which has an exceptionally rich type system, we could write the same thing like this:

fn color_to_hex(color: Color) -> String {
    match color {
        Color::Green => "#00FF00".to_string(),
        Color::Blue => "#0000FF".to_string(),
        Color::Red => "#FF0000".to_string(),
    }
}

Notice how there isn't any case for an unknown enum variant? That's because the Rust code will fail to compile (a step that happens before the code runs at all) if the types don't line up. The Rust compiler enforces that a Color value can only be one of the defined variants, and the match color block is required to handle every variant!

This static enforcement is a huge benefit of sum types. It's a shame we can't get that in Python.

How Does the Match Statement Work?

Python has a match statement that tends to be a lot cleaner than a series of if/elif/else statements when we're working with a fixed set of possible values (like a sum type, or more specifically an enum):

def get_hex(color: Color) -> str:
    match color:
        case Color.RED:
            return "#FF0000"
        case Color.GREEN:
            return "#00FF00"
        case Color.BLUE:
            return "#0000FF"

        # default case (invalid Color)
        case _:
            return "#FFFFFF"

The value we want to compare is set after the match keyword, which is then compared against different cases/patterns. If a match is found, the code in the block is executed.

Can Match Compare Multiple Values?

If you have two values to match, you can use a tuple:

class Shade(Enum):
    LIGHT = 1
    DARK = 2

def get_hex(color: Color, shade: Shade) -> str:
    match (color, shade):
        case (Color.RED, Shade.LIGHT):
            return "#FFAAAA"
        case (Color.RED, Shade.DARK):
            return "#AA0000"
        case (Color.GREEN, Shade.LIGHT):
            return "#AAFFAA"
        case (Color.GREEN, Shade.DARK):
            return "#00AA00"
        case (Color.BLUE, Shade.LIGHT):
            return "#AAAAFF"
        case (Color.BLUE, Shade.DARK):
            return "#0000AA"

        # default case (invalid combination)
        case _:
            return "#FFFFFF"

Each case describes a full combination, and the _ default catches anything you didn't list.

Frequently Asked Questions

Does Python have sum types?

Not natively the way statically typed languages like Rust do. Python doesn't enforce types before your code runs, so you simulate sum types using subclasses with isinstance checks, union type hints like Parsed | ParseError, or the Enum class for a fixed set of values.

What is the difference between a sum type and a product type?

A product type can hold many or even infinite combinations of values, like an object with several boolean attributes. A sum type has a fixed number of possible cases and is exactly one of them at a time, such as a result or an error, or a color that is red, green, or blue.

What is a union type in Python?

A union type is a type hint that says a value may be one of several types, written with the | operator, like str | None or Parsed | ParseError. It documents the shape of your data for editors and type checkers but is not enforced at runtime.

When should you use a match statement instead of if/elif?

Use match when you are comparing one value against a fixed set of possibilities, like the members of an enum, or when matching multiple values at once with a tuple. It stays flatter and more readable than long if/elif/else chains.

What is the difference between the two enum syntaxes in Python?

The functional syntax, Color = Enum('Color', ['RED', 'GREEN', 'BLUE']), is shorter, while the class-based syntax with explicit name = value assignments is more verbose but safer because it avoids ambiguity between the variable name and the enum's class name.

Related Articles