Have you ever found yourself lost in a big repository of Python code, struggling to keep track of the intended types of variables? Without the proper use of type hints and annotations, uncovering variable types can become a tedious and time-consuming task. Perhaps you’re an avid user of type hints but aren’t sure how to annotate methods that return self
or other instances of the class itself. That’s the issue that you’ll tackle in this tutorial.
First, though, you’ll need to understand what type hints are and how they work. Type hints allow you to explicitly indicate variable types, function arguments, and return values. This can make your code more readable and maintainable, especially as it grows in size and complexity.
You specify variable and function argument types with a colon (:
) then the data type, while return value annotations use a dash–greater than symbol (->
) then the return type. To see an example, you can write a function that accepts as input the number of pies that you bought and the price per pie then outputs a string summarizing your transaction:
>>> def buy_pies(num_pies: int, price_per_pie: float) -> str:
... total_cost = num_pies * price_per_pie
... return f"Yum! You spent ${total_cost} dollars on {num_pies} pies!"
...
In buy_pies()
, you type the num_pies
variable with int
and price_per_pie
with float
. You annotate the return value with the str
type because it returns a string.
Note: It’s possible to type hint the total_cost
local variable as total_cost: float
. While this may seem like a good idea, the type checker can automatically infer the type of total_cost
from num_pies
and price_per_pie
, making the annotation unnecessary for total_cost
.
Types and annotations in Python usually don’t affect the functionality of the code, but many static type checkers and IDEs recognize them. For instance, if you hover over buy_pies()
in VS Code, then you can see the type of each argument or return value:
You can also use annotations when working with classes. This can help other developers understand which return type to expect from a method, which can be especially useful when working with complex class hierarchies. You can even annotate methods that return an instance of the class.
One use case for types and annotations is to annotate methods that return an instance of their class. This is particularly useful for class methods and can prevent the confusion that arises when working with inheritance and method chaining. Unfortunately, annotating these methods can be confusing and cause unexpected errors. A natural way to annotate such a method is to use the class name, but the following won’t work:
# incorrect_self_type.py
from typing import Any
class Queue:
def __init__(self):
self.items: list[Any] = []
def enqueue(self, item: Any) -> Queue:
self.items.append(item)
return self
In the example above, .enqueue()
from Queue
appends an item to the queue and returns the class instance. While it might seem intuitive to annotate .enqueue()
with the class name, this causes both static type checking and runtime errors. Most static type checkers realize that Queue
isn’t defined before it’s used, and if you try to run the code, then you’ll get the following NameError
:
Traceback (most recent call last):
...
NameError: name 'Queue' is not defined
There would also be issues when inheriting from Queue
. In particular, a method like .enqueue()
would return Queue
even if you called it on a subclass of Queue
. Python’s Self
type can handle these situations, offering a compact and readable annotation that takes care of the subtleties of annotating methods returning an instance of the enclosing class.
In this tutorial, you’ll explore the Self
type in detail and learn how to use it to write more readable and maintainable code. In particular, you’ll see how to annotate a method with the Self
type and make sure your IDE will recognize this. You’ll also examine alternative strategies for annotating methods that return a class instance and explore why the Self
type is preferred.
Free Bonus: Click here to download the sample code that you’ll use to annotate methods with Python’s Self
type.
How to Annotate a Method With the Self Type in Python
Due to its intuitive and concise syntax, as defined by PEP 673, the Self
type is the preferred annotation for methods that return an instance of their class. The Self
type can be imported directly from Python’s typing
module in version 3.11 and beyond. For Python versions less than 3.11, the Self
type is available in typing_extensions
.
As an example, you’ll annotate a stack data structure. Pay particular attention to your .push()
annotation:
1# stack.py
2
3from typing import Any, Self
4
5class Stack:
6 def __init__(self) -> None:
7 self.items: list[Any] = []
8
9 def push(self, item: Any) -> Self:
10 self.items.append(item)
11 return self
12
13 def pop(self) -> Any:
14 if self.__bool__():
15 return self.items.pop()
16 else:
17 raise ValueError("Stack is empty")
18
19 def __bool__(self) -> bool:
20 return len(self.items) > 0
You import the Self
type from typing
in line 3 and annotate .push()
with -> Self
in line 9. This tells static type checkers that .push()
returns a Stack
instance, allowing you to confidently chain multiple pushes together. Note that it’s usually not necessary to annotate self
and cls
parameters, as discussed in PEP 484.
Note: You may have noticed that you implement .__bool__()
to check whether the stack is empty. This method is a part of Python’s data model and is known as a dunder or special method. In this case, defining .__bool__()
allows you to call the bool()
built-in function from either inside or outside the class to check whether the stack is empty.
The inclusion of .__bool__()
enables the utilization of the class in Pythonic conditionals, such as if not stack: ...
, as the expression within an if
statement is assessed using bool()
internally. This forms the basis for determining whether values are deemed truthy or falsy in Boolean terms.
For Python versions less than 3.11, you can use the typing_extensions
module to import the Self
type, and you can leave the remaining code unchanged:
# stack.py
from typing import Any
from typing_extensions import Self
# ...
By importing Self
from typing_extensions
, you can use Self
to annotate methods in the same way that you would use the typing
module in Python 3.11.
Note: typing_extensions
is a third-party library that you install with pip
. Because typing
is part of the standard library, it can only be updated in regular releases of Python itself, so typing_extensions
is necessary to backport new features to old Python versions.
As you learned, .push()
appends items to the stack and returns the updated stack instance, necessitating the Self
annotation. This allows you to sequentially chain .push()
methods to a stack instance, making your code more concise and readable:
>>> from stack import Stack
>>> stack = Stack()
>>> stack.push(1).push(2).push(3).pop()
3
>>> stack.items
[1, 2]
In the above example, you instantiate a Stack
instance, push three elements to the stack in sequence, and pop one element. By including Self
as the annotation, you can examine .push()
directly from an instantiated object to see what it returns:
When you hover over .push()
in VS Code, you can see that the return type is Stack
, as the annotation notes. With this annotation, others who read your code won’t have to look at the Stack
definition to know that .push()
returns the class instance.
Next, you’ll look at a class that represents the state and logic of a bank account. The BankAccount
class supports several actions, such as depositing and withdrawing funds, that update the state of the account and return the class instance. For example, .deposit()
takes a dollar amount as input, increments the internal balance of the account, and returns the instance so that you can chain other methods:
# accounts.py
from dataclasses import dataclass
from typing import Self
@dataclass
class BankAccount:
account_number: int
balance: float
def display_balance(self) -> Self:
print(f"Account Number: {self.account_number}")
print(f"Balance: ${self.balance:,.2f}\n")
return self
def deposit(self, amount: float) -> Self:
self.balance += amount
return self
def withdraw(self, amount: float) -> Self:
if self.balance >= amount:
self.balance -= amount
else:
print("Insufficient balance")
return self
In BankAccount
, you annotate .display_balance()
, .deposit()
, and .withdraw()
with Self
to return an instance of the class. You can instantiate BankAccount
and deposit or withdraw funds an arbitrary number of times:
>>> from accounts import BankAccount
>>> account = BankAccount(account_number=1534899324, balance=50)
>>> (
... account.display_balance()
... .deposit(50)
... .display_balance()
... .withdraw(30)
... .display_balance()
... )
Account Number: 1534899324
Balance: $50.00
Account Number: 1534899324
Balance: $100.00
Account Number: 1534899324
Balance: $70.00
BankAccount(account_number=1534899324, balance=70)
Here, you define a BankAccount
instance with an account number and initial balance. You then chain multiple methods that perform deposits, withdrawals, and balance displays, each of which returns self
. The REPL automatically prints the return value of the last expression in the method chain, .display_balance()
. Its output, BankAccount(account_number=1534899324, balance=50)
, provides a nice representation of the class.
Note: You may have noticed that BankAccount
is a data class. Data classes are a great way to define classes, and they’re equipped with many useful features. Because BankAccount
is a data class, you don’t need to define the constructor, and the class has a nice string representation from the default .__repr__()
method.
Other use cases for the Self
type are class methods and inheritance hierarchies. For instance, if a parent and its child class have methods that return self
, then you can annotate both with the Self
type.
Interestingly, when a child object calls a parent method that returns self
, type checkers will indicate that the method returns an instance of the child class. You can see this idea by creating a SavingsAccount
class that inherits from BankAccount
:
# accounts.py
import random
from dataclasses import dataclass
from typing import Self
# ...
@dataclass
class SavingsAccount(BankAccount):
interest_rate: float
@classmethod
def from_application(
cls, deposit: float = 0, interest_rate: float = 1
) -> Self:
# Generate a random seven-digit bank account number
account_number = random.randint(1000000, 9999999)
return cls(account_number, deposit, interest_rate)
def calculate_interest(self) -> float:
return self.balance * self.interest_rate / 100
def add_interest(self) -> Self:
self.deposit(self.calculate_interest())
return self
SavingsAccount
has a .from_application()
method that creates a class instance from applicant parameters rather than through the regular constructor. It also has .add_interest()
, which deposits interest to the account balance and returns the class instance. You can boost the readability and maintainability of both these methods with the Self
type.
Next, create a SavingsAccount
object from a new application, make deposits and withdrawals, and add interest:
>>> from accounts import SavingsAccount
>>> savings = SavingsAccount.from_application(deposit=100, interest_rate=5)
>>> (
... savings.display_balance()
... .add_interest()
... .display_balance()
... .deposit(50)
... .display_balance()
... .withdraw(30)
... .add_interest()
... .display_balance()
... )
Account Number: 3631051
Balance: $100.00
Account Number: 3631051
Balance: $105.00
Account Number: 3631051
Balance: $155.00
Account Number: 3631051
Balance: $131.25
SavingsAccount(account_number=3631051, balance=131.25, interest_rate=5)
VS Code recognizes that .from_application()
returns an instance of SavingsAccount
:
When you hover over .from_application()
, the type checker indicates that the return type is SavingsAccount
. VS Code also recognizes that the return type of .deposit()
is SavingsAccount
, despite this method’s being defined in the BankAccount
parent class:
Overall, the Self
type is an intuitive and Pythonic choice for annotating methods that return self
or, more generally, a class instance. Static type checkers recognize Self
, and you can import the symbol so running the code doesn’t cause name errors.
In the next sections, you’ll explore alternatives to the Self
type and see their implementations. Self
is quite new, and several alternative approaches existed before Self
was added. You may encounter these other annotations while reading through old code, so it’s important to understand how they work and what their limitations are.
Annotating With TypeVar
Another way to annotate methods that return an instance of their class is to use TypeVar
. A type variable is a type that can function as a placeholder for a specific type during type checking. Type variables are often used for generic types, such as lists of specific objects like list[str]
and list[BankAccount]
.
TypeVar
allows you to declare parameters for generic types and function definitions, making it a valid candidate for annotating methods that return a class instance. To use TypeVar
in this context, you can import it from Python’s typing
module and give a name to your type in the constructor:
# stack.py
from typing import TypeVar
TStack = TypeVar("TStack", bound="Stack")
In the example above, you create the TStack
type variable, which you can use to annotate .push()
in the Stack
class. In this case, TStack
is bound by Stack
, allowing the type variable to materialize as Stack
or a subtype of Stack
. You can now annotate methods with TStack
:
1# stack.py
2
3from typing import Any, TypeVar
4
5TStack = TypeVar("TStack", bound="Stack")
6
7class Stack:
8 def __init__(self) -> None:
9 self.items: list[Any] = []
10
11 def push(self: TStack, item: Any) -> TStack:
12 self.items.append(item)
13 return self
14
15 # ...
In line 11, you annotate .push()
with the TStack
type. Also notice that you’ve annotated the self
parameter with TStack
. This is required for the static type checker to properly materialize TStack
as Stack
. A TypeVar
that’s bound by a class can materialize as any subclass. This comes in handy in your BankAccount
and SavingsAccount
examples:
# accounts.py
from typing import TypeVar
# Create TBankAccount type bound by the BankAccount class
TBankAccount = TypeVar("TBankAccount", bound="BankAccount")
Here, TBankAccount
is bound by BankAccount
, allowing you to properly annotate the methods that return self
in BankAccount
:
# accounts.py
from dataclasses import dataclass
from typing import TypeVar
TBankAccount = TypeVar("TBankAccount", bound="BankAccount")
@dataclass
class BankAccount:
account_number: int
balance: float
def display_balance(self: TBankAccount) -> TBankAccount:
print(f"Account Number: {self.account_number}")
print(f"Balance: ${self.balance:,.2f}\n")
return self
# ...
You annotate .display_balance()
with TBankAccount
to specify that it’ll return a class instance. It’s important to remember that TBankAccount
isn’t the same as BankAccount
. Rather, it’s a type variable that represents the BankAccount
type during type checking.
TBankAccount
serves no other purpose than to represent the BankAccount
type in annotations where you can’t use BankAccount
directly. You can also use this type to annotate methods in the SavingsAccount
child class:
# accounts.py
import random
from dataclasses import dataclass
from typing import TypeVar
TBankAccount = TypeVar("TBankAccount", bound="BankAccount")
# ...
@dataclass
class SavingAccount(BankAccount):
interest_rate: float
@classmethod
def from_application(
cls: type[TBankAccount], deposit: float = 0, interest_rate: float = 1
) -> TBankAccount:
# Generate a random seven-digit bank account number
account_number = random.randint(1000000, 9999999)
return cls(account_number, deposit, interest_rate)
# ...
You annotate SavingsAccount.from_application()
with the TBankAccount
type variable, and you annotate the cls
parameter with type[TBankAccount]
. Most static type checkers should recognize this as valid type hinting for both BankAccount
and SavingsAccount
.
The main drawback is that TypeVar
is verbose, and a developer can easily forget to instantiate a TypeVar
instance or properly bind the instance to a class. It’s also important to note that not all IDEs recognize TypeVar
when inspecting methods. These are the primary reasons why the Self
type is preferred over TypeVar
.
In the next section, you’ll explore another alternative to Self
and TypeVar
, the __future__
module. You’ll see how this method overcomes the verbosity of TypeVar
but still isn’t preferred over the Self
type because it doesn’t support inheritance well.
Using the __future__
Module
Python’s __future__
module offers a different approach to annotating methods that return the enclosing class. The __future__
module is sometimes used to introduce incompatible changes that are intended to become part of a future Python version.
For Python versions greater than 3.7, you can import the annotations
feature from the __future__
module at the top of a script and use the class name directly as the annotation. You can see this with the Stack
class:
1# stack.py
2
3from __future__ import annotations
4
5from typing import Any
6
7class Stack:
8 def __init__(self) -> None:
9 self.items: list[Any] = []
10
11 def push(self, item: Any) -> Stack:
12 self.items.append(item)
13 return self
14
15 # ...
In line 3, you import annotations
from __future__
, allowing you to use annotation features that might not be available in the version of Python you’re using. In line 11, you use the class name directly as the annotation for .push()
. You can see the annotation when inspecting .push()
in the same way as earlier.
Note: You must make any imports from the __future__
module at the top of the script. This is required because __future__
changes the way Python code is parsed, allowing the use of incompatible features.
Under the hood, the annotations aren’t executed but get stored as strings that can be executed later. This way of evaluating annotations has raised some discussion about potentially better ways to do this in future Python versions.
While the __future__
module works for annotating methods with the class name, this isn’t a best practice because the Self
type is more intuitive and Pythonic. Plus, remembering to import from __future__
at the top of the script can be a hassle. More importantly, inheritance isn’t properly supported when annotating with __future__
. Have a look at what happens to SavingsAccount
methods when you use __future__
annotations:
# accounts.py
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Self
@dataclass
class BankAccount:
account_number: int
balance: float
# ...
def deposit(self, amount: float) -> BankAccount:
self.balance += amount
return self
# ...
@dataclass
class SavingsAccount(BankAccount):
interest_rate: float
@classmethod
def from_application(
cls, deposit: float = 0, interest_rate: float = 1
) -> SavingsAccount:
# Generate a random seven-digit bank account number
account_number = random.randint(1000000, 9999999)
return cls(account_number, deposit, interest_rate)
def calculate_interest(self) -> float:
return self.balance * self.interest_rate / 100
def add_interest(self) -> SavingsAccount:
self.deposit(self.calculate_interest())
return self
The code above redefines SavingsAccount
, which inherits from BankAccount
. Notice how you’ve annotated .deposit()
in BankAccount
with BankAccount
, and you’ve annotated the methods returning self
in SavingsAccount
with SavingsAccount
. Everything appears fine, but look what happens when you check the type of .deposit()
from an instance of SavingsAccount
:
The return type of .deposit()
is displaying as BankAccount
even though this object is an instance of SavingsAccount
. This happens because SavingsAccount
inherits from BankAccount
, and the __future__
annotation doesn’t properly support inheritance. This creates even more type checking issues when you inspect .add_interest()
:
The type check of .add_interest()
fails because the type checker thinks that deposit()
returns a BankAccount
instance, but BankAccount
has no methods named .add_interest()
. This illustrates the most prominent flaw of __future__
annotations. While __future__
annotations may work for many classes, they’re not appropriate when typing inherited methods.
In the next section, you’ll explore an annotation that’s functionally similar but more direct than __future__
annotations. This will help you understand what __future__
is doing under the hood. Keep in mind that all the alternative annotations for methods that return class instances are no longer considered best practices. You should opt for the Self
type, but it’s good to understand the alternatives because you might come across them in code.
Type Hinting With Strings
Lastly, you can use strings to annotate methods that return class instances. You should use the string annotation for Python versions less than 3.7, or when none of the other approaches work. String annotation doesn’t require any imports, and most static type checkers recognize it:
# stack.py
from typing import Any
class Stack:
def __init__(self) -> None:
self.items: list[Any] = []
def push(self, item: Any) -> "Stack":
self.items.append(item)
return self
# ...
In this case, the string annotation should contain the name of the class. Otherwise, the static type checker won’t recognize the return type as a valid Python object. String annotations directly accomplish something similar to what __future__
annotations do behind the scenes.
One major drawback of string annotations is that they’re not preserved with inheritance. When a subclass inherits a method from a superclass, the annotations specified as strings in the superclass don’t automatically propagate to the subclass. This means that if you rely on string annotations for type hinting or documentation purposes, then you’ll need to redeclare the annotations in each subclass, which can be error-prone and time-consuming.
Many developers also find the syntax of string annotations to be unusual or non-idiomatic compared to other features of Python. In the early versions of Python 3, when type hints were introduced, string annotations were the only available option. However, with the introduction of the typing
module and the type hints syntax, you now have a more standard and expressive way to annotate types.
Conclusion
Using type hints and annotations in Python can make your code more readable and maintainable, especially as it grows in size and complexity. By indicating the types of variables, function arguments, and return values, you can help other developers understand the intended types of variables and what to expect from function calls.
The Self
type is a special type hint that you can use to annotate a method that returns an instance of its class. This makes the return type explicit and can help prevent subtle bugs that might arise when working with inheritance and subclassing. While you can use other options like TypeVar
, the __future__
module, and strings to annotate methods returning class instances, you should use the Self
type when possible.
By importing the Self
type from the typing
module—or from typing_extensions
in Python 3.10 and earlier—you can annotate methods that return a class instance, making your code more maintainable and easier to read.
Free Bonus: Click here to download the sample code that you’ll use to annotate methods with Python’s Self
type.