Django mengandalkan metaprogramming dinamis pada ORM yang mempermudah ekspresi kueri, tetapi menyembunyikan risiko fatal di runtime. Kesalahan referensi seperti pemanggilan reverse relation yang salah ketik, dereferensi atribut pada nullable field tanpa guard clause, atau asumsi return type QuerySet slicing sering kali lolos dari unit test dasar dan meledak sebagai AttributeError di production.

Solusi standar industri untuk masalah ini adalah analisis statis menggunakan mypy yang dipadukan dengan plugin django-stubs. Integrasi ini memetakan tipe runtime Django ke sistem tipe Python secara deterministik sejak tahap Continuous Integration (CI).

Akar Masalah: Kelemahan Dynamic Typing pada Django ORM

Secara default, Django ORM menginjeksi atribut secara dinamis saat inisialisasi class model. Tiga pola berikut merupakan sumber umum bug runtime:

  • Nullable Fields: Field dengan null=True mengembalikan nilai Union[T, None]. Mengakses properti langsung tanpa pengecekan is None akan memicu AttributeError saat nilai bernilai null.
  • Reverse Relations: Relasi ForeignKey atau OneToOneField secara otomatis menyuntikkan reverse manager ke model target. Penamaan implisit ini tidak terdeteksi oleh editor atau linter konvensional tanpa definisi stubs.
  • QuerySet Slicing vs Indexing: Sintaksis Model.objects.all()[:1] menghasilkan instance QuerySet, sedangkan Model.objects.all()[0] menghasilkan instance model tunggal atau melempar IndexError. Tipe statis memvalidasi kontrak ini secara ketat.

Konfigurasi mypy dan django-stubs via pyproject.toml

Letakkan seluruh konfigurasi type checker pada pyproject.toml untuk menjaga repositori tetap bersih dan terpusat.

[tool.mypy]
python_version = "3.11"
plugins = ["mypy_django_plugin.main"]
strict_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_unreachable = true
disallow_untyped_defs = false
check_untyped_defs = true

[tool.django-stubs]
django_settings_module = "config.settings.ci_typecheck"

Catatan: Parameter check_untyped_defs = true memastikan fungsi tanpa type hint tetap diperiksa isi logikanya terhadap tipe objek yang dikenali, memberikan proteksi tanpa memaksa anotasi 100% secara langsung.

Isolasi DJANGO_SETTINGS_MODULE untuk Pemeriksaan Statis

Plugin django-stubs memerlukan akses ke settings Django untuk menginspeksi INSTALLED_APPS dan memetakan model relasional. Menjalankan mypy menggunakan settings development atau production yang menginisialisasi koneksi database, vault secrets, atau Redis cache adalah kesalahan fatal di pipeline CI.

Buat file konfigurasi minimal yang independen dari dependensi eksternal: config/settings/ci_typecheck.py.

from config.settings.base import *  # noqa: F403

SECRET_KEY = "ci-static-typecheck-secret-key-only"
DEBUG = False

# Cegah handshake jaringan ke database eksternal
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": ":memory:",
    }
}

# Nonaktifkan storage eksternal dan backend cache berat
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.dummy.DummyCache",
    }
}
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.MD5PasswordHasher",
]

Pendekatan ini menjamin parsing AST model berjalan dalam hitungan milidetik tanpa memerlukan container database berjalan di runner CI.

Typed Custom Manager dan QuerySet Chaining

Salah satu kendala terbesar saat menambahkan static typing pada Django adalah mempertahankan chaining kueri kustom. Menggunakan typing.cast di seluruh codebase menciptakan technical debt dan mematikan fungsi verifikasi mypy. Pola kanonikal yang benar menggunakan generik dari django-stubs:

from typing import Self
from django.db import models

class OrderQuerySet(models.QuerySet["Order"]):
    def paid(self) -> Self:
        return self.filter(status="PAID")

    def high_value(self, threshold: int = 1_000_000) -> Self:
        return self.filter(total_amount__gte=threshold)

class OrderManager(models.Manager["Order"]):
    def get_queryset(self) -> OrderQuerySet:
        return OrderQuerySet(self.model, using=self._db)

class Order(models.Model):
    STATUS_CHOICES = (
        ("PENDING", "Pending"),
        ("PAID", "Paid"),
    )
    status = models.CharField(max_length=20, choices=STATUS_CHOICES)
    total_amount = models.IntegerField()

    objects = OrderManager.from_queryset(OrderQuerySet)()

# Validasi Chaining: Valid secara statis tanpa cast()
def audit_orders() -> None:
    orders = Order.objects.paid().high_value()
    for order in orders:
        print(order.total_amount)

Penggunaan Self (atau type variable terikat) mempertahankan konteks kelas saat fungsi di-chain, sehingga pemanggilan metode berurutan tidak turun kembali menjadi generic QuerySet[Any].

Strategi Adopsi Bertahap pada Legacy Codebase

Menjalankan mypy dengan aturan ketat secara instan pada repositori lama akan memicu ratusan error. Gunakan per-module strictness pada pyproject.toml untuk mengunci modul baru secara ketat sambil memberi toleransi pada modul lama.

# Modul inti/finansial dengan validasi ketat
[[tool.mypy.overrides]]
module = "apps.billing.*"
disallow_untyped_defs = true
disallow_any_generics = true

# Modul legacy: izinkan peringatan tanpa mematahkan pipeline
[[tool.mypy.overrides]]
module = "apps.legacy_auth.*"
ignore_errors = true

Tim engineering dapat menerapkan aturan baru: setiap file yang disentuh atau modul baru wajib lulus aturan disallow_untyped_defs = true.

Otomasi Pipeline CI dengan GitHub Actions

Eksekusi analisis statis harus berlangsung cepat agar tidak memperlambat feedback loop pull request. Kunci performa mypy pada repositori besar adalah mempertahankan cache inkremental (.mypy_cache).

name: Static Type Analysis

on:
  pull_request:
    branches: [main, master]
  push:
    branches: [main]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install Dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install mypy django-stubs

      - name: Cache mypy internal storage
        uses: actions/cache@v4
        with:
          path: .mypy_cache
          key: mypy-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}-${{ github.sha }}
          restore-keys: |
            mypy-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}-
            mypy-${{ runner.os }}-

      - name: Run mypy
        run: |
          mypy apps/

Pemanfaatan restore-keys memungkinkan mypy membaca metadata AST run sebelumnya. Hal ini memotong durasi pemeriksaan dari menit menjadi hitungan detik pada build bertahap.