Bug paling menyebalkan biasanya bukan soal logika yang keliru, tapi tipe data yang tidak cocok. Fungsi kamu mengharapkan int, dipanggil dengan str. Variabel yang kamu kira selalu berisi ternyata None di satu cabang. Python tidak peduli saat kode ditulis — dia baru melempar TypeError atau AttributeError ketika baris itu benar-benar dieksekusi, dan seringnya itu terjadi di produksi, bukan di laptop kamu. mypy adalah alat yang membaca kode Python tanpa menjalankannya, menebak tipe setiap variabel, lalu melaporkan ketidakcocokan sebelum runtime. Yang kamu butuhkan cuma Python 3.8 ke atas dan satu file untuk dicoba.

Type hints saja tidak cukup

Sejak Python 3.5, kamu bisa menulis anotasi tipe langsung di tanda tangan fungsi. Contoh kecil:

python
def total_harga(items: list[dict]) -> float:
    return sum(item["harga"] for item in items)

Sekilas aman. Tapi panggil dengan data seperti ini:

python
total_harga([{"harga": 15000}, {"nama": "Buku"}])

Baris itu meledak dengan KeyError: 'harga' saat dijalankan. Anotasi list[dict] tidak menyelamatkan apa pun, karena Python menyimpan anotasi sebagai metadata di __annotations__ dan tidak pernah memeriksanya. Kalau kamu pernah menulis TypeScript di JavaScript, konsepnya mirip dengan static typing di TypeScript — bedanya, di Python pengecekannya ditambahkan lewat alat eksternal seperti mypy, bukan bagian bawaan bahasa.

Instal mypy

Pasang lewat pip:

bash
pip install mypy

Kalau proyek kamu pakai uv, tambahkan sebagai dev dependency supaya tidak ikut terbawa ke produksi:

bash
uv add --dev mypy

Menjalankan mypy pertama kali

Simpan dua potongan kode total_harga tadi ke berkas store.py, lalu jalankan:

bash
mypy store.py

Hasilnya: Success: no issues found in 1 source file. Kok lolos? Karena list[dict] terlalu longgar — dict bisa berisi apa saja, termasuk objek tanpa kunci harga. mypy hanya bisa memeriksa apa yang bisa dibuktikan dari anotasi yang kamu beri. Makin spesifik anotasinya, makin tajam laporannya.

Anotasi dasar yang wajib kamu kuasai

Mulai dari tipe bawaan, lalu gabungkan untuk koleksi:

python
def bagi(a: int, b: int) -> float:
    return a / b

def sapa(nama: str) -> str:
    return "Halo, " + nama

def jumlahkan(data: dict[str, int]) -> int:
    return sum(data.values())

# dua baris yang salah tipe
sapa(123)
jumlahkan({"a": "bukan angka"})

Jalankan mypy dan kamu dapat laporan begini:

python
store.py:10: error: Argument 1 to "sapa" has incompatible type "int"; expected "str"  [arg-type]
store.py:11: error: Dict entry 1 has incompatible type "str": "str"; expected "str": "int"  [dict-item]

Setiap error punya kode di akhir dalam kurung siku — arg-type, dict-item — yang nanti berguna untuk menonaktifkan error secara selektif. Untuk nilai yang boleh kosong, pakai tanda pipa:

python
def cari_user(nama: str) -> dict[str, str] | None:
    if nama == "admin":
        return {"nama": "Admin", "role": "superuser"}
    return None

Biasakan menangani None. mypy akan menolak kode yang membaca atribut dari nilai bertipe X | None tanpa guard if x is None. Itu bukan rewel — error 'NoneType' object has no attribute ... adalah salah satu crash paling umum di Python.

TypedDict dan Protocol untuk tipe yang lebih presisi

Kembali ke contoh total_harga. Daripada list[dict], gunakan TypedDict supaya mypy tahu persis kunci apa yang wajib ada:

python
from typing import TypedDict

class Item(TypedDict):
    harga: int
    nama: str

def total_harga(items: list[Item]) -> int:
    return sum(item["harga"] for item in items)

total_harga([{"harga": 15000, "nama": "Buku"}])   # lolos
total_harga([{"nama": "Buku"}])                    # error: Missing key "harga"  [typeddict-item]

Untuk duck typing — fungsi yang hanya peduli pada sebagian antarmuka objek — ada Protocol:

python
from typing import Protocol

class PunyaNama(Protocol):
    nama: str

def cetak(obj: PunyaNama) -> None:
    print(obj.nama)

Protocol membuat mypy menerima objek apa pun selama punya atribut nama, tanpa mewajibkan pewarisan. Kalau kebutuhanmu bukan sekadar cek statis, tapi validasi data saat runtime juga, arahkan ke Pydantic — keduanya bisa dipakai berdampingan: mypy untuk cek sebelum kode jalan, Pydantic untuk memvalidasi input yang datang dari luar.

Konfigurasi mypy lewat pyproject.toml

Setiap kali memanggil mypy tanpa argumen, ia membaca konfigurasi di pyproject.toml. Mulai dari sini:

python
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true

Mode strict menyalakan banyak flag sekaligus, termasuk disallow_untyped_defs (setiap fungsi wajib punya anotasi) dan check_untyped_defs. Untuk kode lama yang belum siap, turunkan standar per modul:

python
[[tool.mypy.overrides]]
module = ["legacy.*"]
disallow_untyped_defs = false

Gradual typing untuk kode lama

Tidak realistis memaksa seluruh proyek lolos strict dalam sehari. mypy dirancang untuk gradual typing: biarkan fungsi lama tanpa anotasi, anotasi yang baru kamu ubah. Untuk melihat tipe yang ditebak mypy pada satu variabel, pakai reveal_type():

python
def hitung(x):
    reveal_type(x)
    return x * 2

Jalankan mypy dan ia mencetak tipe yang berhasil disimpulkan — cara tercepat memahami kenapa sebuah baris dianggap bermasalah.

Jalankan mypy di CI

Cek tipe cuma berguna kalau dijalankan terus-menerus, bukan sekali sebulan. Di GitHub Actions, cukup begini:

python
name: type-check
on: [push, pull_request]
jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install mypy
      - run: mypy .

Kalau kamu belum punya pipeline, tutorial GitHub Actions dari nol akan membantu. Setelah CI hijau, pertimbangkan juga menambah mypy ke pre-commit supaya cek berjalan setiap commit.

Error umum dan cara membacanya

Salah satu error yang paling sering muncul adalah union-attr, yaitu membaca atribut dari nilai yang mungkin None:

python
def nama_lengkap(user: dict[str, str] | None) -> str:
    return user["nama"] + " " + user["belakang"]  # error: union-attr
python
def nama_lengkap(user: dict[str, str] | None) -> str:
    if user is None:
        return "Tanpa Nama"
    return user["nama"] + " " + user["belakang"]

Jangan menebar # type: ignore sembarangan. Any dan # type: ignore menghentikan pengecekan. Kalau benar-benar terpaksa, sertakan kode error-nya supaya niatmu jelas dan bisa diaudit: # type: ignore[arg-type]. Abaikan tanpa alasan hanya menutupi bug asli.

Jalankan mypy . di proyek kamu sekarang. Kalau hasilnya puluhan error, itu normal untuk kode lama. Bereskan dari berkas yang paling sering berubah, lalu kunci dengan strict = true di pyproject.toml. Begitu cek tipe hijau, tambahkan ke CI — biarkan mesin yang menjaga tipe, bukan ingatanmu.