Welcome back to the second lesson in the Functional Patterns & Pattern Matching in Python course! In the previous lesson, we mastered production-grade decorators by building a configurable retry mechanism with exponential backoff and jitter. We learned how decorators can wrap functions to add reusable behavior while preserving metadata and type safety.
Today, we're exploring another powerful functional pattern: single dispatch generic functions. While decorators modify function behavior, generic functions adapt their implementation based on the type of their arguments. This pattern is essential when you need different behavior for different data types without cluttering your code with long chains of isinstance checks. We'll build a JSON serializer that handles datetime objects, Decimal numbers, custom data classes, and nested containers, all using Python's functools.singledispatch decorator. By the end of this lesson, you'll be able to create extensible, type-aware functions that are easy to maintain and extend.
A generic function is a function that performs conceptually the same operation but implements it differently depending on the input type. For example, serializing an object to JSON requires different strategies: a string stays as is, a datetime needs ISO format conversion, and a custom object might need dictionary transformation.
The traditional approach uses conditional logic:
This approach has several problems. Adding support for new types requires modifying the function, violating the open/closed principle. The logic becomes harder to read as more types are added. Most importantly, third-party code can't extend the function without modifying your source.
Generic functions solve these issues by separating the concept (serialize this object) from the implementation (how to serialize each specific type). This separation makes the code more modular, testable, and extensible.
Python's functools.singledispatch decorator implements the single dispatch pattern, which selects an implementation based on the type of a single argument (typically the first). The pattern works through registration: you define a base function that handles the default case, then register specialized implementations for specific types.
Here's the basic structure:
The base function decorated with @singledispatch establishes the generic function. The .register decorator adds type-specific implementations. Notice the implementation function is named _: since we access the function through the generic name to_json_serializable, the individual implementation names don't matter. This convention signals that these are internal implementation details.
Let's start building our JSON serializer by defining the base generic function. The base implementation handles the case when no specific registration matches the input type:
This base function does three important things:
- It establishes
to_json_serializableas a generic function using@singledispatch - It accepts any type of object with the
Anyannotation - It raises a descriptive error for unsupported types, making debugging easier
When we call this function with an object for which no registration exists, it falls back to this base implementation and raises a TypeError. This fail-fast behavior is better than returning None or attempting a generic conversion that might produce incorrect results.
Now let's register implementations for types that aren't natively JSON serializable. We'll start with Decimal and datetime, two common types that need special handling:
Each registration follows the same pattern:
- The
@to_json_serializable.registerdecorator attaches this implementation to the generic function - The type hint on the parameter (
obj: Decimalorobj: datetime) determines when this implementation is used - The function body provides the type-specific conversion logic
If you're not using type annotations, you can pass the type explicitly to the decorator itself, such as @to_json_serializable.register(int).
For Decimal, we convert to a string to preserve exact precision. For datetime, we use isoformat(), which produces ISO 8601 format like "2024-01-02T03:04:05+00:00". These implementations are automatically selected based on the argument type at runtime.
Generic functions shine when handling custom data classes. Let's define a Money class and register a serializer for it:
The Money class is a frozen dataclass (as you may recall from previous courses) that enforces currency codes and rounds amounts to two decimal places. The registered serializer converts it to a dictionary structure that JSON can handle. Notice how the registration keeps the serialization logic separate from the class definition, maintaining a clean separation of concerns.
When serializing containers, we can't call to_json_serializable directly on every object because some types like strings and numbers are already JSON-compatible; we need an helper function instead. This helper function decides whether to pass an object through the generic function or return it unchanged:
The serialize function optimizes the common case: primitive types that are already JSON-compatible bypass the generic function entirely. For everything else, it delegates to to_json_serializable, which dispatches to the appropriate registered implementation based on the object's type.
The separation between serialize and to_json_serializable is important: the generic function handles type-specific conversions, while serialize provides a convenient entry point that handles both primitives and complex types uniformly. We'll use this helper extensively when handling containers recursively.
Now that we have our helper function, we can handle containers like lists. Serializing containers requires recursion: we need to serialize each element within the container:
The list registration uses serialize recursively on each element, ensuring nested structures are handled correctly. Without the recursive call, a list containing Money or Decimal objects would fail serialization. The recursion allows arbitrarily deep nesting: lists of lists, lists of dictionaries containing Money objects, and so on.
Dictionaries and tuples need similar recursive treatment, but with additional considerations. Dictionaries must have string keys for JSON compatibility, and tuples should be converted to lists:
Each registration handles its container type appropriately:
- Tuples become lists since JSON doesn't distinguish between them
- Dictionaries convert all keys to strings (using
str(k)) and recursively serialize values - Sets also become lists, losing their uniqueness guarantee but preserving their elements
The dictionary registration is particularly important because Python allows non-string keys like tuples or numbers, but JSON requires string keys. Converting keys to strings ensures valid JSON output.
Let's test our serializer with a complex nested structure containing multiple types. We'll create a payload with datetime, Decimal, Money objects, collections, and even a tuple key:
This payload exercises every aspect of our serializer: primitive values, datetime conversion, decimal handling, custom Money objects, nested lists, sets, tuple keys, and tuple values. The output demonstrates successful serialization:
Notice several transformations in the output: the datetime is now an ISO string, Decimal values are strings, the Money objects are dictionaries, the set became a list, and the tuple key is stringified. The second line item shows rounding: "9.495" became "9.50" due to the Money class's post-init validation.
The base implementation ensures graceful failure when encountering unsupported types. Let's test this behavior:
The generic function correctly identifies that no registration exists for the Foo class and raises a descriptive error. This fail-fast approach prevents silent data corruption or mysterious serialization failures. If we needed to support Foo, we'd simply add a registration rather than modifying the base function.
We can also serialize custom objects directly when they have a registered implementation:
The Money object serializes correctly on its own, showing that our generic function works both as part of recursive container serialization and as a standalone converter.
The single dispatch pattern provides significant advantages for long-term code maintenance. Adding support for new types requires only a new registration, not modifying existing code. Third-party packages can extend your generic function by importing it and adding registrations for their types. Testing becomes easier because each type handler is isolated and can be tested independently.
Compare this to the conditional approach: every new type requires editing the central function, increasing the risk of introducing bugs. Type-specific logic becomes intertwined, making it harder to understand individual cases. The dispatch approach keeps concerns separated, following the single responsibility principle.
The pattern also supports inheritance: if you register a handler for a base class, it will be used for subclasses unless a more specific registration exists. This allows you to create hierarchical type handlers that share common logic while specializing where needed.
You've now mastered single dispatch generic functions, a powerful pattern for writing type-aware code that remains clean and extensible. We built a complete JSON serializer that handles primitive types, custom classes, and nested containers through recursive dispatch. By separating type-specific logic into individual registrations, we created a system that's easy to understand, test, and extend.
The generic function pattern complements the decorator pattern from our previous lesson: while decorators modify behavior regardless of input types, generic functions adapt behavior based on type. Together, these patterns form essential tools in functional programming, allowing you to write code that's both flexible and maintainable.
You've seen how functools.singledispatch transforms conditional logic into a clean registry of type handlers, how recursive serialization handles nested structures, and how fail-fast error handling prevents silent failures. These concepts will serve you well as you build more sophisticated systems.
Now it's time to solidify your understanding through hands-on practice. In the upcoming exercises, you'll implement registrations for various types, debug recursive serialization, handle unsupported types, and extend the serializer with new capabilities. Let's put your newfound knowledge to the test!
