Generic Typing in Python

Generic Typing in Python

Featured on Hashnode

Since Python introduced the ability to add generic types to functions, the language has become much more type-friendly and encourages you to follow this convention for more maintainable code. Obviously, this will lead to a better development experience.

Stay with me as we discuss the basics of Generic Typing in Python and some best practices at the end.

What Does Generic Typing Help With?

This fascinating feature allows you to make type-checking more dynamic. Sometimes, we don't know the types of input parameters.

Before we continue, I encourage you to take a moment to try solving this problem.

How would you type-hint a function that takes one parameter and that’s a list of objects of any type (int, str, User, Book, etc) and returns just one random object of that type?

Any Turns off the Type-Checking!

During that challenge, you might have considered the following thought processes. Let's take a look.

Thought Process 1:

My function should take one parameter, which is a list of any type (List[Any]), and return a random single item from that list (Any).

def select(items: List[Any]) -> Any:
    return random.choice(items)

Answer:

This is incorrect. Although your type-checker might still pass the type-checking, there is a significant mistake in this typing. List[Any] means a list that can be empty or filled with items of any type. This allows the function to be called in a way that violates the requirement that the parameter should be a list of items of the same type.

select([1, "b", "c", "d", False, 3.14])

The type Any somehow neutralizes the type-checker and hides its pointer from being type-checked.

item: Any = ...  # just like we've put `type: ignore` here

Be very careful with Any and make sure you really need its effect.


Thought Process 2:

I can explicitly define what type my parameter can take. That way, not only I haven’t used the type Any, I have explicitly defined the input and output type.

def select(items: List[int | str | bool | float]) -> int | str | bool | float:
    return random.choice(items)

Answer:

This is a better solution, but it still doesn't fully meet the challenge where we said the function parameter could be anything. It might not be a string or an integer, for example. This function only works for the built-in types. What if I wanted to call it like this?

from typing import NewType

def select(...):
    .

User = NewType("User", str)
users = [User("Abby"), User("Chris"), User("Nick")]

user = select(users)
file.py:12: error: Argument 1 to "select" has incompatible type "list[User]"; expected "list[int | str | bool | float]"  [arg-type]

Not only did our type-checker (mypy) fail to check it, but it also doesn't meet the challenge.

Using Generic Typing

It’s a newly added syntax to Python. Yet I haven’t seen many people using it as they are still releasing for older Python versions like 3.8, 3.9, 3.10, and 3.11. Generic typing was introduced in Python 3.12.

Now, let’s talk about how we can use this feature to solve the challenge we described earlier.

Structure and Syntax

The structure is quite simple. You define new type variables and use them accordingly when defining the function. Just like defining variables that don't have a value at the time of declaration, you define placeholders in brackets right before the parentheses. (Here, I've declared only one variable/placeholder named T)

def FUNCTION_NAME[TYPES](ARGS..) -> TYPE:
def select[T](items...

And you can use it later when specifying the type of parameters and the return value of your function.

def select[T](items: List[T]) -> T:
    ...

Now, let’s define the above function. It has declared one T type variable and says, the items parameter can be a list of anything and I’ll consider that as T for now. That T is the placeholder for any time that items might have.

select(items=[1, 2, 3, 4, 5]) # T=int   &  List[T] = List[int]
select(items=["a", "b", "c"]) # T=str   &  List[T] = List[str]
select(items=[1.2, 1.3, 1.4]) # T=float &  List[T] = List[Float]

# Therefore, we have..
users = [User("Abby"), User("Chris"), User("Nick")]
select(items=users)           # T=User  &  List[T] = List[User]

As you can see, the type represented by T changes dynamically based on the values we put in the items parameter. We can now explain the select() function like this:

  • Function definition: def select[T](items: List[T]) -> T:

  • Type variable(s)/placeholder(s): T

  • Input type: List of any similar type

  • Output type: A type similar to one of the list items

Let’s Practice

P1: Now, let's say our function takes only one item and returns a tuple containing three of that item.

Answer:

def convert[B](item: B) -> Tuple[B, B, B]:
    return (item, item, item)

Hint: We use the letter "T" as the default primary type placeholder by convention, but you can choose any name you prefer as in the above example, we used the letter “B”.

P2: Let’s say we need a function that takes two parameters of different types and returns a tuple containing those items.

Answer:

def convert[A, B](first: A, second: B) -> Tuple[A, B]:
    return (first, second)

P3: A function that takes an ID and a list of dictionaries, where each dictionary has exactly three pairs (id, username, and is_verified), and returns the item that matches the given ID.

Hint: Avoid defining the type Users in this example to better understand Generic Typing. Defining it would make Generics seem unnecessary in this case.

Answer: You figure it out! :))

Conclusion

Generic Typing is a pretty new and hot topic in Python. It's helpful when defining functions and classes. In this article, we explored the function side of this powerful syntax. Hopefully, you found it useful.