Skip to content

Typing in the context of dynamic languages 3: Defining custom types in Python

How to use our own types to create new subtypes to make our typing even more precise, especially in terms of our business logic?

In the previous sections, I gave an overview on subtyping and how to best use it, including in the case of variance and generic types. We want to use the most precise types possible at all times, but in the case of generic types with contravariant parameters, this means using supertypes for those parameters.

Next we will use our own types to create new subtypes. Let’s start with defining new types.

The simplest form of defining a new type is making a type alias. But that is just a new name for a type and does not create a subtype.

Age = int
 
def birthday(a: Age) -> Age:
    return a+1
 
 
a: Age = 10
i: int = 10
 
birthday(a)

# this works even, showing that Age is not a subtype of int
birthday(i)

To actually create a subtype, we need to use typing.NewType. This requires us to add some “casts” into the new type (actually class instantiation):

from typing import NewType
 
Age = NewType('Age', int)
 
def birthday(a: Age) -> Age:
    return Age(a+1)
 
 
a: Age = Age(10)
i: int = 10

# an Age can be used as an int, its supertype 
reveal_type(a + i)

birthday(a)
birthday(i)  # this does not work

note: Revealed type is "builtins.int"
error: Argument 1 to "birthday" has incompatible type "int"
expected "Age"

Type aliases are meant to make it easier to write longer types, but NewType makes your type checker enforce that you don’t “compare apples to oranges” as we are taught in primary school, as you shouldn’t be able to use a temperature where you expect an age, for instance.

Defining new generics

You can use type aliases or NewTypes to define new generic types, or you can parametrize a new class. To illustrate this, we will use an analogy of executables for certain operating systems:

class OS:
    pass
 
class Desktop(OS):
    pass
 
class Mobile(OS):
    pass
 
class Windows(Desktop):
    pass
 
class Linux(Desktop):
    pass
 
class Android(Mobile):
    pass
 
class IOS(Mobile):
    pass

Starting from these definitions, we can define new types based on existing generics, for instance list:

Network = list[Desktop]

But we can also define our own new generic classes:

from typing import Generic, TypeVar
 
T = TypeVar('T')
 
class Executable(Generic[T]):
    pass

The latter example is actually flawed as there is nothing stopping us from creating an Executable[int]. Our generic type is not precise enough because by default, our type variable is only a subclass of object. To specify the supertype of a variable, we use the bound parameter of the TypeVar:

from typing import Generic, TypeVar
 
T = TypeVar('T', bound=OS)
 
class Executable(Generic[T]):
    pass

Finally, we can also specify that a type parameter is covariant or contravariant in our generic type. These use cases are of course a little more niche, so we will just leave them here without going any deeper.

Desk_co = TypeVar('Desk_co', bound=Desktop, covariant=True)
Mob_ct = TypeVar('Mob_ct', bound=Mobile, contravariant=True)
 
class Network(Generic[Desk_co]):
    pass
 
class Notifier(Generic[Mob_ct]):
    pass

Protocols

The last type of subtyping we will look at is completely unrelated to classes or explicit subtyping. It is a de facto subtyping, that we usually call “duck typing” (“If it walks like a duck and it quacks like a duck, then it must be a duck”), but is properly called structural subtyping: if an object has the methods given for a specific “interface”, what in Python is called a Protocol, then it is a subtype of this protocol.

from typing import Protocol
 
class Duck(Protocol):
    def walk(self) -> None:
        ...
    def quack(self) -> str:
        ...
 
# explicit implementation of the protocol
class Mallard(Duck):
    def walk(self) -> None:
        pass
    def quack(self) -> str:
        return "Quack"
 
# implicit implementation of the protocol
class Mandarin:
    def walk(self) -> None:
        pass
    def quack(self) -> str:
        return "Kwak"
    def fly(self) -> None:
        pass

As you can see from the last example, to be a subtype of the protocol, you need to implement at least the given methods, so any object implementing the proper methods with the proper types is automatically a subtype of the protocol.

This is a very powerful feature. Moreover, the protocols act as abstract base classes, so if you use those in your codebase, you might want to consider replacing them with protocols to reap the benefits.

My good practices

We have seen how subtyping can help the static type checker work efficiently for you and prove properties about your system. In conclusion, here is my summary of recommendations:

Be sure to understand your business domain well, as your types and their relationships should reflect it as well as possible, so that your type system will prove properties that matter.

Define new types as soon as they make sense. If your function works on usernames, create a username type instead of using str.

Think twice about subtyping relationships. Inheritance can still make sense sometimes, but you can also replace it with protocols and composition.

Read more about the typing possibilities that Python offers by going through the documentation and the PEP.

Search