Python's Self Type: How to Annotate Methods That Return self

Python's Self Type: How to Annotate Methods That Return self

by Harrison Hoffman intermediate best-practices python

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:

Python
>>> 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.

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:

Python
# 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:

Python Traceback
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.

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:

Python
 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.

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:

Python
# 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.

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:

Python
>>> 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:

VS Code recognizes the push method return type
VS Code recognizes the return type of .push()

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:

Python
# 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:

Python
>>> 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.

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:

Python
# 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:

Python
>>> 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:

VS Code Recognizes the Return Type of  an Inherited Method
VS Code recognizes the return type of .from_application()

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:

VS Code Recognizes the Return Type of an Inherited Method
VS Code recognizes the return type of an inherited method

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:

Python
# 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:

Python
 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:

Python
# 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:

Python
# 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:

Python
# 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:

Python
 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.

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:

Python
# 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:

SavingsAccount method return type is BankAccount
Inherited methods from SavingsAccount are incorrectly annotated with BankAccount

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 .add_interest() method fails to type because .deposit() returns a BankAccount
The type check of .add_interest() fails because .deposit() is incorrectly annotated

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:

Python
# 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.

🐍 Python Tricks 💌

Get a short & sweet Python Trick delivered to your inbox every couple of days. No spam ever. Unsubscribe any time. Curated by the Real Python team.

Python Tricks Dictionary Merge

About Harrison Hoffman

Harrison is an avid Pythonista, Data Scientist, and Real Python contributor. He has a background in mathematics, machine learning, and software development. Harrison lives in Texas with his wife, identical twin daughters, and two dogs.

» More about Harrison

Each tutorial at Real Python is created by a team of developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Master Real-World Python Skills With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

Master Real-World Python Skills
With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

What Do You Think?

Rate this article:

What’s your #1 takeaway or favorite thing you learned? How are you going to put your newfound skills to use? Leave a comment below and let us know.

Commenting Tips: The most useful comments are those written with the goal of learning from or helping out other students. Get tips for asking good questions and get answers to common questions in our support portal.


Looking for a real-time conversation? Visit the Real Python Community Chat or join the next “Office Hours” Live Q&A Session. Happy Pythoning!