Type Hints di Python: dari Dasar hingga Pola Backend yang Praktis

Panduan bertahap memakai type hints Python untuk backend: mulai dari annotation sederhana, TypedDict, Protocol, generics, hingga integrasi mypy, Pyright, dan Pydantic v2.

Type hints di Python sering diajarkan sebagai fitur dokumentasi atau “biar IDE lebih pintar”. Manfaatnya memang dua itu, tapi kalau dipakai dengan benar, type hints menjadi kontrak antar bagian kode: fungsi, service, repository, dan API jadi punya ekspektasi eksplisit yang bisa dicek sebelum runtime.

Sayangnya, banyak tutorial menulis type hints seperti hiasan: semua variabel dianotasi, pakai Any di mana-mana, atau malah langsung nyalakan mypy --strict di codebase lama dan kaget dengan ribuan error. Tulisan ini mencoba jalan tengah: mulai kecil, pakai di tempat yang paling berdampak, dan baru menambahkan pola lanjutan saat memang dibutuhkan.

Mulai dari Fungsi

Titik awal terbaik adalah parameter dan return type fungsi. Dua tempat ini memberikan rasio manfaat-terhadap-kebisingan tertinggi.

def create_user(email: str, age: int, is_active: bool = True) -> dict[str, str]:
    return {"email": email, "status": "active" if is_active else "inactive"}

Dengan annotation di atas, editor langsung tahu:

  • email harus string.
  • age harus integer.
  • is_active opsional dengan default True.
  • Fungsi mengembalikan dictionary dengan key string dan value string.

Gunakan sintaks Python 3.10+ agar lebih ringkas: str | None alih-alih Optional[str], list[str] alih-alih List[str], dan dict[str, int] alih-alih Dict[str, int].

Jangan over-annotate variabel lokal yang tipenya sudah jelas dari konteks. Checker seperti mypy dan Pyright sering bisa infer sendiri, jadi terlalu banyak annotation justru membuat kode berisik.

Static Checking vs Runtime Validation

Perbedaan fundamental yang harus dipahami: type hints tidak secara otomatis menolak nilai salah saat program berjalan. Mypy dan Pyright adalah static checker; mereka membaca kode tanpa mengeksekusinya dan menemukan ketidakkonsistenan sebelum deploy.

# mypy akan protes sebelum kode dijalankan
create_user(email=123, age="twenty")  # error: tipe tidak cocok

Tapi saat aplikasi menerima input eksternal dari HTTP request, JSON, atau database, kita butuh validasi runtime. Di sinilah Pydantic masuk.

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    email: EmailStr
    age: int = Field(ge=0, le=120)
    is_active: bool = True

Pydantic tidak menggantikan type hints; dia menggunakan type hints untuk parsing dan validasi input. Banyak contoh populer masih menunjukkan Pydantic v1. Untuk project baru, gunakan Pydantic v2 karena perbedaan API-nya cukup signifikan.

TypedDict untuk Bentuk JSON Sederhana

Kalau kamu sering berurusan dengan payload JSON atau dict hasil parsing yang tidak perlu dijadikan model penuh, TypedDict adalah pilihan tepat.

from typing import TypedDict


class CreateUserPayload(TypedDict):
    email: str
    display_name: str

TypedDict memberitahu checker bentuk dictionary yang diharapkan tanpa runtime cost. Ini cocok untuk intermediate representation, konfigurasi, atau struktur yang memang lebih natural sebagai dict daripada class.

Bedanya dengan Pydantic: TypedDict tidak melakukan validasi runtime. Kalau field salah atau hilang, error baru muncul saat kode mengaksesnya, bukan saat parsing. Jadi gunakan TypedDict untuk boundary internal dan Pydantic untuk boundary eksternal.

Protocol untuk Duck Typing yang Terstruktur

Python dikenal dengan duck typing. Protocol memungkinkan kita mendefinisikan kontrak tanpa memaksa class untuk inherit dari base class tertentu.

from typing import Protocol

from pydantic import BaseModel


class UserOut(BaseModel):
    id: int
    email: str
    display_name: str


class UserRepo(Protocol):
    async def find_by_email(self, email: str) -> UserOut | None: ...
    async def create(self, payload: CreateUserPayload) -> UserOut: ...

Sekarang fungsi service bisa menerima objek apa pun yang memenuhi kontrak tersebut:

async def register_user(payload: CreateUserPayload, repo: UserRepo) -> UserOut:
    existing = await repo.find_by_email(payload["email"])
    if existing is not None:
        raise ValueError("email already registered")
    return await repo.create(payload)

Kamu bisa mengganti implementasi repo antara in-memory test, SQLAlchemy, atau Django ORM tanpa mengubah register_user. Ini lebih fleksibel daripada abstract base class karena tidak memaksa inheritance.

Generics untuk Container yang Bisa Dipakai Ulang

Generics berguna saat kita punya struktur data yang sama tapi berisi tipe berbeda. Contoh paling umum di backend adalah response paginasi.

from typing import Generic, TypeVar

from pydantic import BaseModel, Field


T = TypeVar("T")


class Page(BaseModel, Generic[T]):
    items: list[T]
    total: int = Field(ge=0)
    limit: int = Field(gt=0, le=100)


# penggunaan
page_of_users: Page[UserOut] = Page(items=users, total=100, limit=20)

Dengan generics, checker tahu bahwa page_of_users.items adalah list[UserOut], bukan list[Any].

Adopsi Bertahap

Tidak perlu langsung membuat seluruh codebase strict. Jalannya yang paling realistis:

  1. Mulai anotasi fungsi dan class di file yang sedang kamu ubah.
  2. Aktifkan checker dengan mode longgar di CI, misalnya python.analysis.typeCheckingMode = "basic" di VS Code.
  3. Perjelas boundary eksternal dengan Pydantic.
  4. Secara bertahap perketat mypy atau Pyright per modul sampai tim nyaman.

Konfigurasi pyproject.toml bisa terlihat seperti ini:

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

[tool.pyright]
typeCheckingMode = "basic"
pythonVersion = "3.12"

Menyalakan strict di seluruh codebase lama sekaligus adalah resep frustrasi. Lebih baik tambahkan py.typed, aktifkan strict untuk package baru, dan biarkan modul lama dikecualikan sementara.

Di Mana Typing Menjadi Berisik?

Type hints tidak selalu worth it. Ada situasi di Python yang membuat strict typing mahal:

  • Kode yang sangat dinamis: dict nested dalam dict, attribute yang dibuat runtime, atau framework dengan banyak magic.
  • Monkeypatching: mengubah behavior object di runtime sulit dinyatakan dengan type hints.
  • ORM dan decorator: SQLAlchemy relationship atau decorator custom sering membutuhkan annotation tambahan atau plugin khusus.
  • Kode framework internal: terkadang lebih baik menyerahkan pada Any dengan komentar penjelasan daripada memaksa annotation yang rapuh.

Tujuan typing adalah mengurangi bug dan meningkatkan readability. Kalau sebuah annotation justru membuat kode lebih sulit dibaca, pertimbangkan untuk tidak menuliskannya.

Kesimpulan

Type hints di Python paling berharga ketika dipakai sebagai kontrak antar komponen, bukan sebagai dekorasi di setiap baris. Mulai dari parameter dan return type fungsi, gunakan Pydantic untuk validasi runtime, dan kenali pola backend seperti TypedDict, Protocol, serta generics saat mereka benar-benar mempermudah desain.

Adopsi bertahap jauh lebih berkelanjutan daripada big-bang strict mode. Kalau kamu sedang memperkenalkan type hints di tim, mulai dari file baru atau modul yang sering berubah, lalu perluas perlahan sambil menjaga CI tetap hijau.

Ada pola typing favorit yang sering kamu pakai di backend? Tulis di komentar; saya ingin tahu bagian mana yang menurut tim-mu paling worth the effort.