Skip to content
← All Python articles

Python explainer

Choose a Function by Argument Type with singledispatch

About 3 min to read. Allow extra time to work through examples.

Replace a chain of isinstance checks with handlers registered per type, so other modules can add types without editing your function.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your app turns values into JSON-friendly data. Integers stay integers, Decimal values become strings, and an unknown type is an error. A chain of isinstance checks works until another module adds a Money type, and then someone has to edit your function. With functools.singledispatch, that module registers its own handler instead:

from decimal import Decimal
from functools import singledispatch

@singledispatch
def encode(value: object) -> object:
    raise TypeError(f"no encoding for {type(value).__name__}")

@encode.register
def encode_int(value: int) -> object:
    return int(value)

@encode.register
def encode_decimal(value: Decimal) -> object:
    return str(value)

print(encode(3), encode(Decimal("1.50")))  # 3 1.50
print(encode(True))                        # 1

The @singledispatch decorator makes encode the fallback for any type that has no handler. Each @encode.register reads the type annotation on the handler's first parameter and stores the handler under that type. On every call, encode looks at the type of its first argument and runs the matching handler. Only the first argument counts, and you must pass it by position. A call such as encode(value=3) raises TypeError.

A subclass uses its parent's handler

The call encode(True) printed 1. A bool is a subclass of int, and with no bool handler, singledispatch picks the handler for the closest parent class. That handler's int(value) call then turns True into 1. Register bool separately if you want to keep booleans.

singledispatch always picks the closest class. For a string, it picks a str handler over a Sequence handler, because str is the more specific class.

Register containers by their class

At runtime, a list does not record the type of its items, so list[int] cannot pick a handler. For a handler annotated value: list[object], register raises a TypeError that says "list[object] is not a class". Pass the class yourself, and keep the detailed annotation on the handler:

def encode_list(value: list[object]) -> object:
    return [encode(item) for item in value]

encode.register(list, encode_list)
print(encode([2, Decimal("0.25"), True]))  # [2, '0.25', 1]

The handler calls encode on each item, so a list of any registered type works, including a list of lists.

Registrations last as long as the function

The registry belongs to encode itself. Registering the same class again replaces its handler, and nothing ever removes one. A class defined inside a function is a new class on every call, so registering it on every call adds a new entry each time. Register long-lived types once, when your program starts.

The exercise registers a class from inside solve on purpose. It shows that a handler for one call's class does not match the next call's class.

Methods use singledispatchmethod

For a method, use singledispatchmethod. It picks the handler by the first argument after self:

from functools import singledispatchmethod

class Formatter:
    @singledispatchmethod
    def format(self, value: object) -> str:
        return str(value)

    @format.register
    def format_int(self, value: int) -> str:
        return f"integer: {value}"

print(Formatter().format(4))       # integer: 4
print(Formatter().format("four"))  # four
Check your understanding Another module defines Money and wants encode(Money(250)) to return {"cents": 250}. What does it write, and what changes in your module? Show answer

It writes a handler such as def encode_money(value: Money) -> object in its own module and decorates it with @encode.register. Your module does not change at all, which is the point of singledispatch. With a chain of isinstance checks, someone would have to edit your function for each new type.

Going further

  • Since Python 3.11, register also accepts a union, so one handler annotated value: int | float covers both types.
  • A value can match two registered abstract types from collections.abc equally well. For example, with handlers for both Sized and Iterable, the call encode([1]) raises RuntimeError, because a list is both. Register the concrete class, here list, to settle it.

The functools documentation gives the full rules.

step 30 of 55

Everyday Standard Library Tools

Up next Build a JSON Converter Other Code Can Extend