I have an existing Django project which uses a custom User model class that extends AbstractUser. For various important reasons, we need to redefine the email field as follows:

class User(AbstractUser):
    ...
    email = models.EmailField(db_index=True, blank=True, null=True, unique=True)
    ...

Typing checks via mypy have been recently added. However, when I perform the mypy check, I get the following error:

error: Incompatible types in assignment (expression has type "EmailField[str | int | Combinable | None, str | None]", base class "AbstractUser" defined the type as "EmailField[str | int | Combinable, str]") [assignment]

How can I make it so that mypy allows this type reassignment? I don't wish to just use # type: ignore because I wish to use its type protections.

For context, if I do use # type: ignore, then I get dozens of instances of the following mypy error instead from all over my codebase:

error: Cannot determine type of "email" [has-type]

Here are details of my setup:

python version: 3.10.5
django version: 3.2.19
mypy version: 1.6.1
django-stubs[compatible-mypy] version: 4.2.6
django-stubs-ext version: 4.2.5
typing-extensions version: 4.8.0
3

There are 3 answers

0
Alan M. On

One option is to change the super class from AbstractUser to AbstractBaseUser. This would allow to to more easily over-write the specific properties you want to change and give you more flexibility in the long run, at the cost of some extra boilerplate.

class User(AbstractBaseUser, PermissionsMixin):
   ...
   email = models.EmailField(db_index=True, blank=True, null=True, unique=True)
   ...

Some extra info: https://testdriven.io/blog/django-custom-user-model/

0
rafmsou On

One option is to overwrite the django stubs to be compatible with your user model.

Create a stub file (.pyi) for django and override the type for AbstractUser in there, see here.

11
VonC On

You could try and use type annotations that are compatible with the AbstractUser base class, but also allow None values for the email field in your custom User model.

from typing import Optional
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    email: Optional[models.EmailField] = models.EmailField(db_index=True, blank=True, null=True, unique=True)

If you explicitly type annotate the email field as Optional[models.EmailField], that would indicate that email can be of type models.EmailField or None, which should satisfy the requirements of both Django and mypy.

By using the Optional type annotation, you would maintain the integrity of your custom User model, but also adhering to mypy's type checking standards. No more "incompatible types" error.


Incompatible types in assignment (expression has type "EmailField[Any, Any] | None", base class "AbstractUser" defined the type as "EmailField[str | int | Combinable, str]") [assignment]

It means the type definition for your email field in the custom User model is not fully compatible with the type expected by the AbstractUser class. Given that mypy is strict about type consistency, especially when overriding fields in a subclass, you will need to make sure your field definition matches the expected type in AbstractUser while also allowing None.

Create a type variable that encompasses the types allowed by AbstractUser and your additional requirement of None.
And use this type variable in the field definition to satisfy both the base class's and your custom requirements.

from typing import Union, TypeVar
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.fields import Combinable

# Define a TypeVar that includes the types from AbstractUser and None
EmailFieldType = TypeVar("EmailFieldType", models.EmailField, None)

class User(AbstractUser):
    email: Union[EmailFieldType, models.EmailField[str | int | Combinable, str]] = models.EmailField(db_index=True, blank=True, null=True, unique=True)

EmailFieldType is now a type variable that allows the email field to be either a models.EmailField or None, aligning with the types specified in AbstractUser. The use of Union in the field definition should accommodate the specific type requirements from AbstractUser as well as your custom model's need to allow None.


error: Type variable "myproject.models.User.EmailFieldType" is unbound`

Instead of using a TypeVar, you could directly use Union to specify that the email field can be either of the types required by AbstractUser or None. That would simplify the type definition and should be more straightforward for mypy to interpret.

from typing import Union
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.fields import Combinable

class User(AbstractUser):
    email: Union[models.EmailField, None] = models.EmailField(db_index=True, blank=True, null=True, unique=True)

email field is declared as a union of models.EmailField and None. That should satisfy the type requirement for AbstractUser while also allowing None as a value.


Unfortunately the same issue is happening again

Then you would need a more sophisticated type handling or a workaround that aligns with mypy's expectations, without compromising the integrity of your Django model.

Try and create a custom subclass of models.EmailField that explicitly handles the nullable scenario in a way that is compatible with both Django and mypy.

from django.db import models

class NullableEmailField(models.EmailField):
    def get_prep_value(self, value):
        return super().get_prep_value(value) if value is not None else None

class User(AbstractUser):
    email = NullableEmailField(db_index=True, blank=True, null=True, unique=True)

As a more drastic measure, you can try completely redefining the field and handling the migration carefully. That approach would entail removing the email field from your model and then re-adding it with the new specifications. That is more intrusive and requires careful migration handling.