Tom Wojcik personal blog

A primer on creating custom types in Python

☕️ 4 min read

Intro

Since Python 3, classes and types are pretty much the same thing.

A class is a blueprint for creating objects, which defines the attributes and methods that the objects will have. When you create an instance of a class, you are creating an object with those attributes and methods.

A type is the category to which an object belongs. Every object in Python has a type, which defines the set of operations that can be performed on it. For example, the type of an integer is int and the type of a string is str.

Classes are used to create new types. When you define a class, you are defining a new type of object. The type of the objects created from a class is the class itself.

Consider the following class definition:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

In this case, Person is a class that defines the structure and behavior of a Person object. When this class is defined, it creates a new type object called Person. This type object can be used to create new instances of the Person class

>>> alice = Person("Alice", 25)
>>> type(alice)
<class '__main__.Person'>

This type can be used to statically type your code.

def get_person() -> Person:
    return Person(name="Alice", age=25)

1. typing.TypeVar PEP 484

added in Python 3.5

typing.TypeVar allows you to create type variables that can be used to specify types in a flexible way. Type variables can be used to define generic functions, classes, and protocols that can work with different types.

import random
import typing

T = typing.TypeVar('T')


def get_random_item(items: typing.List[T]) -> T:
    return random.choice(items)


class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age


alice = Person("Alice", 25)
bob = Person("Bob", 35)

get_random_item([alice, bob])
get_random_item(['not alice', 'not bob'])

In this example, we create a type variable T using TypeVar('T'), which represents an unknown type. We can use this type variable to define a function that takes a list of items of type T and returns a random item from that list, which is also of type T.

TypeVar shines when used with Generic, if you can find a use for it in your codebase.

import typing

T = typing.TypeVar('T')


class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age


class ObjectsStore(typing.Generic[T]):
    def __init__(self):
        self.objects: list[T] = []

    def append(self, obj: T):
        self.objects.append(obj)


class PeopleStore(ObjectsStore[Person]):
    pass


people_store = PeopleStore()

alice = Person("Alice", 25)
people_store.append(alice)

people_store.append('asd')

In the dummy example above, when creating a class PeopleStore, we tell it to inherit from ObjectsStore and that the type T is Person, so all objects within PeopleStore need to be of type People.

When we run mypy on this, we see

main.py:29: error: Argument 1 to “append” of “ObjectsStore” has incompatible type “str"; expected "Person” [arg-type]

2. typing.NewType PEP 484

added in Python 3.5

typing.NewType is a typing module that allows you to create new types that are NOT aliases for existing types.

from typing import NewType

PersonAge = NewType("PersonAge", int)


class Person:
    def __init__(self, name, age: PersonAge):
        self.name = name
        self.age = age

    def __str__(self):
        return self.name


alice = Person("Alice", 25)

Even though PersonAge was derived from int type, mypy complains.

main.py:15: error: Argument 2 to “Person” has incompatible type “int"; expected "PersonAge” [arg-type]

We’re forced to cast it to the correct type, even though it’s an instance of int.

>>> alice = Person("Alice", PersonAge(25))
>>> isinstance(PersonAge(25), int)
True
>>> alice.age.__class__.mro()
[<class 'int'>, <class 'object'>]
>>> alice.age is 25
True

I think it’s best to think about it as a special kind of alias, because static type checkers will know not to treat it as such.

3. typing.TypeAlias PEP 613

added in Python 3.10

typing.TypeAlias allows you to create a new name for an existing type. It is essentially an alias for the type, which means that the new type is equivalent to the original type for both - human and static type checker.

from typing import TypeAlias

PersonAge: TypeAlias = int


class Person:
    def __init__(self, name, age: PersonAge):
        self.name = name
        self.age = age

    def __str__(self):
        return self.name


alice = Person("Alice", 25)

assert isinstance(alice.age, int)
assert type(alice.age) == int

4. types.GenericAlias PEP 585

added in Python 3.9

I’m not sure what’s the use case for it and I assume I will never find one, as typing using the standard library has improved a lot since GenericAlias was added. For example, these two are identical:

>>> assert GenericAlias(list, Person, ) == list[Person]
>>> True

One use case for GenericAlias is to create custom generic types that are not included in the typing module. For example, suppose you want to create a generic type Pair that represents a pair of two values of potentially different types. You could define it like this:

import types

class Pair:
    def __class_getitem__(cls, params):
        return types.GenericAlias(cls, params)

PairType = Pair[str, int]  # creates a GenericAlias representing Pair[str, int]

In this example, Pair is a custom class that has a __class_getitem__ method, which is called when the class is parameterized with type arguments. This method returns a GenericAlias object that represents the parameterized class.

Though it doesn’t look very useful to me.