Three tips to write better function signatures with positional & keyword parameters, generic functions and function overloads.
This blog post covers three ways to strengthen Python function signatures:
TypeVar@overloadTyping is an intermediate level Python topic. Adding types to your Python program allows you to:
Good Python developers put time, care and effort into type hints for return values and parameters of functions, but they ignore the most powerful contract mechanism available: the function signature itself.
A contract is an agreement between two parties. In programming, a function signature defines the contract between the function and its callers - the stronger the contract, the happier the world.
Too much freedom in how a function is called also increases the number of mistakes a function caller can make when using a function.
/ and * SyntaxThe first solution to tightening the function signature contract is to constrain how callers can pass parameters using / and * in your function signature.
Many Python functions accept arguments in ways that invite bugs. Consider the function signature below:
def process_data(data, encoding="utf-8", strict=False) -> None:
pass
This signature permits all of the below as valid uses of the process_data function:
process_data(my_data)
process_data(my_data, "latin-1")
process_data(my_data, strict=True)
process_data(my_data, "latin-1", True)
process_data(encoding="utf-8", data=my_data)
All of these different valid function calling patterns makes refactoring the function difficult. If we change the order of function arguments, or change parameter names, we will break code that relied on that order or those names.
The solution is to use / and * in the function signature to constrain how parameters can be passed. The function below divides parameters into three zones:
/ to force parameters to be passed positionally* to force parameters to be passed as keywords/ and * can be passed either waydef process_data(
data: pd.DataFrame, /, mode: str = "strict", *, encoding: str = "utf-8"
) -> None:
pass
In the function above:
data positionallymode as either positional or keyword encoding as a keywordTypevar for Multiple Return TypesTypevar allows polymorphic contracts - generic functions that can work with different return types.
Type hints lose information when functions return the same type they receive:
def first(items: list) -> object:
return items[0]
numbers = [1, 2, 3]
result = first(numbers) # Type: object
print(result + 1) # Type error: can't add object + int
The function works, but the type checker only sees object as the return type. You know it’s an int, but that information is lost.
The solution to this problem is to use TypeVar from the typing module, which preserves type information through functions:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
numbers = [1, 2, 3]
result = first(numbers) # Type: int
print(result + 1) # Works!
words = ["a", "b", "c"]
result = first(words) # Type: str
print(result.upper()) # Works!
The contract now says: “Give me a list of T, I’ll return a T.” The type checker preserves the specific type through the function.
Input type determines output type:
T = TypeVar("T")
def identity(value: T) -> T:
return value
Multiple related type parameters:
K = TypeVar("K")
V = TypeVar("V")
def invert_dict(d: dict[K, V]) -> dict[V, K]:
return {v: k for k, v in d.items()}
Constrained TypeVars limit what types are allowed:
Numeric = TypeVar("Numeric", int, float)
def double(x: Numeric) -> Numeric:
return x * 2
double(5) # Type: int
double(5.0) # Type: float
double("hi") # Type error!
Bounded TypeVars require a subtype:
class Animal:
def make_sound(self) -> str: ...
T = TypeVar("T", bound=Animal)
def get_sound(animal: T) -> T:
animal.make_sound() # Type checker knows T has this method
return animal
TypeVar is for “same type in, same type out” - one polymorphic contract that preserves type information through your function.
Above we saw that TypeVar preserves type information through a function. But what if the return type changes based on input values?
Sometimes one function needs to behave differently based on input types, and the return type changes accordingly. Type checkers can’t infer this from a single signature. The transform function below illustrates the problem, as it returns different types based on the mode parameter:
def transform(data: str, mode: str) -> str | list[str]:
if mode == "split":
return data.split()
return data.upper()
A type checker sees str | list[str] as the return type for all calls, even though you know mode="split" always returns list[str]. This forces defensive checks:
result = transform(text, "split")
# Type checker thinks result could be str or list[str]
for item in result: # Type error: str is not iterable
print(item)
The solution to this problem is to use @overload to declare multiple contracts:
from typing import Literal, overload
@overload
def transform(data: str, mode: Literal["split"]) -> list[str]: ...
@overload
def transform(data: str, mode: Literal["upper"]) -> str: ...
def transform(data: str, mode: Literal["split", "upper"]) -> str | list[str]:
if mode == "split":
return data.split()
return data.upper()
Now the type checker understands:
result = transform(text, "split") # Type: list[str]
for item in result: # No type error
print(item)
result = transform(text, "upper") # Type: str
print(result.lower()) # No type error
The @overload signatures are type-checker-only declarations. The final signature is the actual implementation. This pattern appears throughout typed libraries to provide precise return types based on input values.
Common use cases:
@overload
def fetch_user(user_id: int, *, include_profile: Literal[True]) -> UserWithProfile: ...
@overload
def fetch_user(user_id: int, *, include_profile: Literal[False] = False) -> User: ...
def fetch_user(user_id: int, *, include_profile: bool = False) -> User | UserWithProfile:
# implementation
Type checkers now know that fetch_user(123, include_profile=True) returns UserWithProfile, not just User.
Problems we solve:
object instead of preserving specific typesSolutions:
/ and *): Restrict how parameters can be passed to prevent fragile calling patternsWhen to use each:
/ and * syntax: Start with keyword-only for boolean or configuration parameters, add positional-only when parameter names are unstable or meaningless