← All tutorials
Python

How to Accept Different Object Types in One Python Function

Build one delivery-summary function that works with CSV, in-memory, and combined data sources. A small typing.Protocol documents the shared behavior without forcing inheritance, while executed checks show what runtime validation can and cannot prove.

A report often starts with one kind of input. It reads a CSV file today, but next month it may also receive fresh records already held in memory. Adding an if isinstance(...) branch for every new class makes the report responsible for data loading as well as calculation.

To accept different object types in one Python function, call only the small set of attributes and methods that the function needs. Describe that shared behavior with typing.Protocol so a type checker can verify each class, then normalize every result to one record shape before calculating. Python does not require the classes to share a base class at runtime.

We will apply that pattern to fictional parcel deliveries. One function will summarize a CSV source, an in-memory source, and a source that combines both. None of the three classes will inherit from a shared base class.

Prerequisites and Setup

You need Python 3.10 or later and basic familiarity with functions and classes. A type hint is a note in code that describes an expected value type. You do not need experience with type checkers or class inheritance.

This lesson uses only Python’s standard library. Create and activate a virtual environment so your files stay separate from other projects. On macOS or Linux, run:

python -m venv .venv
source .venv/bin/activate
python --version

On Windows PowerShell, use .venv\Scripts\Activate.ps1 for the activation step. The executed tutorial build used Python 3.13.2 and no third-party packages.

Download delivery_history.csv and save it beside your Python file. It contains six rows:

shipment_id,promised_days,actual_days
H001,2,2
H002,3,5
H003,4,3
H004,2,6
H005,5,5
H006,3,4

This is original synthetic teaching data released under CC0-1.0. Synthetic means the rows were created for this lesson rather than collected from real deliveries. The shipment IDs, promises, and durations are all fictional.

How a Behavior Contract Works

The report needs only two things from an input object:

  1. a label to identify the result; and
  2. a load() method that returns a list of delivery records.

That pair is the contract. A contract describes what an object must provide. It does not prescribe how the object loads its data.

Diagram showing CSV, memory, and combined source classes using one DeliverySource contract before entering the same delay summary function.

Python’s Protocol expresses this idea as structural typing. Structural typing checks whether an object has the required shape. The object’s class name and inheritance tree do not need to match.

There are three separate layers to keep clear:

  • The concrete classes decide how to load and convert data.
  • The protocol documents the small shape the report expects.
  • The report uses only those shared members, without branching on concrete classes.

This design keeps file parsing out of the calculation. It also keeps the calculation out of each input class.

Give Every Source the Same Record Shape

Before accepting several source types, define the value they all return. A frozen data class gives every delivery the same three named fields. frozen=True prevents accidental field assignment after creation.

from dataclasses import dataclass

@dataclass(frozen=True)
class Delivery:
    shipment_id: str
    promised_days: int
    actual_days: int

csv.DictReader reads the duration fields as text, while the in-memory code already uses integers. Each source must handle that difference before returning Delivery objects. The summary function then receives consistent records from both.

Now describe the required source behavior. @runtime_checkable lets us demonstrate isinstance() later; it is not required when a protocol is used only as a type hint.

from typing import Protocol, runtime_checkable

@runtime_checkable
class DeliverySource(Protocol):
    label: str

    def load(self) -> list[Delivery]:
        ...

The ellipsis (...) is a placeholder for a method body. DeliverySource does not load anything itself. It says that a matching object has a string label and a zero-argument load() method that should return list[Delivery].

The Python typing.Protocol documentation calls this static duck typing. Python does not enforce function annotations at runtime. A type checker or editor can inspect the hint before execution, but a running program still calls the methods on the object it receives.

Load CSV and Memory Data Without Type Branches

The first concrete class owns the CSV-specific work. It opens the file, reads each row as a dictionary, and converts the two duration fields from strings to integers.

import csv
from pathlib import Path

class CsvDeliverySource:
    def __init__(self, path: Path, label: str):
        self.path = path
        self.label = label

    def load(self) -> list[Delivery]:
        with self.path.open(newline="", encoding="utf-8") as handle:
            return [
                Delivery(
                    shipment_id=row["shipment_id"],
                    promised_days=int(row["promised_days"]),
                    actual_days=int(row["actual_days"]),
                )
                for row in csv.DictReader(handle)
            ]

Notice that CsvDeliverySource does not inherit from DeliverySource. It still matches the protocol because it has the required label and load() members with compatible types.

The second class receives deliveries that are already in memory. Its load() method returns a new outer list, so a caller cannot change the source’s stored list by appending to the returned value.

class MemoryDeliverySource:
    def __init__(self, deliveries: list[Delivery], label: str):
        self.deliveries = deliveries
        self.label = label

    def load(self) -> list[Delivery]:
        return list(self.deliveries)

Create one object of each class. The live records use different IDs and values from the CSV rows.

archive = CsvDeliverySource(Path("delivery_history.csv"), "CSV history")

live = MemoryDeliverySource(
    [
        Delivery("L001", promised_days=2, actual_days=3),
        Delivery("L002", promised_days=4, actual_days=8),
        Delivery("L003", promised_days=3, actual_days=3),
    ],
    "live memory",
)

At this point the objects are different concrete types. Their load() results have the same shape, which is the part the report needs.

Write One Function for Both Sources

The summary function accepts DeliverySource, calls load(), and works only with normalized Delivery objects. A delivery is late when actual_days exceeds promised_days. An early delivery contributes zero delay rather than a negative number.

def delay_summary(source: DeliverySource) -> dict[str, str | int | float]:
    deliveries = source.load()
    if not deliveries:
        raise ValueError("source returned no deliveries")

    delays = [
        max(delivery.actual_days - delivery.promised_days, 0)
        for delivery in deliveries
    ]
    late_count = sum(delay > 0 for delay in delays)

    return {
        "source": source.label,
        "records": len(deliveries),
        "late": late_count,
        "late_rate_pct": round(100 * late_count / len(deliveries), 1),
        "mean_delay_days": round(sum(delays) / len(deliveries), 2),
    }

The empty-list check has two purposes. It gives the caller a clear error, and it prevents division by zero when calculating the late rate.

Call the same function with both objects. The executed build printed:

for source in (archive, live):
    print(delay_summary(source))
{'source': 'CSV history', 'records': 6, 'late': 3, 'late_rate_pct': 50.0, 'mean_delay_days': 1.17}
{'source': 'live memory', 'records': 3, 'late': 2, 'late_rate_pct': 66.7, 'mean_delay_days': 1.67}

Read the first result from left to right. Three of six historical deliveries were late, so the late rate is 50%. Total non-negative delay is seven days; dividing by all six deliveries gives 1.17 mean delay days. The live source has fewer records and a higher late rate, but the function and output keys stay unchanged.

Add a Third Type Without Editing the Function

A combined view is useful when the report needs both archived and recent deliveries. This class receives any list of DeliverySource objects and joins their loaded records.

class CombinedDeliverySource:
    def __init__(self, sources: list[DeliverySource], label: str):
        self.sources = sources
        self.label = label

    def load(self) -> list[Delivery]:
        return [
            delivery
            for source in self.sources
            for delivery in source.load()
        ]

Again, there is no inheritance statement. The constructor hint tells a type checker that a combined source may contain future source types, provided they follow the same contract.

Build the combined object and pass it to the unchanged summary function:

combined = CombinedDeliverySource([archive, live], "combined view")
print(delay_summary(combined))
{'source': 'combined view', 'records': 9, 'late': 5, 'late_rate_pct': 55.6, 'mean_delay_days': 1.33}

The nine records are the six CSV deliveries plus three live deliveries. Five are late. This is the practical benefit of the contract: a new source type adds loading behavior without adding another branch to delay_summary().

This same boundary helps with testing. For an example using a protocol to replace an external model client, see Test an LLM Pipeline Without API Calls in Python. The domain is different, but the replaceable-object principle is the same.

Understand What Runtime Checking Does Not Prove

Because the protocol has @runtime_checkable, isinstance() can check for its named members. All three working objects pass:

print(isinstance(archive, DeliverySource))
print(isinstance(live, DeliverySource))
print(isinstance(combined, DeliverySource))
True
True
True

This check is shallow. The official runtime_checkable() documentation explains that it checks member presence, not type signatures. The following broken class has a label and a load() method, so it passes the runtime protocol check. However, its method requires an argument that the contract does not allow.

class WrongSignatureSource:
    label = "broken source"

    def load(self, required_path: Path) -> list[Delivery]:
        return []

broken = WrongSignatureSource()
print(isinstance(broken, DeliverySource))

try:
    delay_summary(broken)
except TypeError:
    print("runtime check passed, but calling load() failed")
True
runtime check passed, but calling load() failed

Use a static type checker in a larger project to catch signature mismatches before execution. At an untrusted boundary, such as a file or API, validate the returned data as well. A runtime protocol check alone cannot prove that load() returns deliveries or that their values are sensible.

Common Mistakes and Troubleshooting

Branching on every concrete class. Code such as if isinstance(source, CsvDeliverySource) ties the report to today’s classes. Move conversion into each source and let the report call only the shared method.

Treating Protocol as runtime conversion. A protocol does not turn CSV strings into integers. Each source must return the agreed record shape. Keep parsing close to the format that needs it.

Assuming type hints reject bad values. Python records annotations but does not enforce them when the program calls a function. Run a static checker in development, and add explicit validation where data enters from files, APIs, queues, or users.

Making the protocol too large. If the report needs only label and load(), do not also require logging, caching, saving, and configuration methods. A small protocol is easier for a new class or test double to satisfy.

Trusting isinstance() to inspect signatures. A runtime-checkable protocol checks names, not full signatures or return values. The executed WrongSignatureSource example proves this limitation. Do not use the result as complete input validation.

Catching every TypeError around the report. A broad handler can hide a programming defect inside delay_summary() itself. Validate at a narrow boundary, log useful context, and avoid changing an unexpected coding error into a vague “bad source” message.

Skipping the empty-source decision. Decide whether no records should raise an error, return zero metrics, or return a separate status. This tutorial raises ValueError because a percentage over zero records has no useful meaning for this report.

Recap: Design Around the Smallest Useful Behavior

One Python function can work with many object types when those objects provide the behavior it needs. The function does not need a list of allowed class names.

The durable sequence is:

  1. Normalize source-specific values into one record type.
  2. Describe the smallest required behavior with a protocol.
  3. Implement that behavior in each concrete source class.
  4. Annotate the shared function with the protocol.
  5. Use static checking during development and explicit validation at untrusted boundaries.
  6. Add a new source without editing the calculation.

In the executed example, the same delay_summary() function processed six CSV records, three in-memory records, and their nine-record combination. The protocol made the shared requirement visible, while the failed signature example showed why runtime checks are only one layer of confidence.

As a next step, add a DatabaseDeliverySource whose load() method runs a fixed query and returns Delivery objects. If the summary function needs no changes, the boundary is doing its job.

More tutorials