Bab 33 — Type Hint: Memberi Label pada Data, Memeriksa Sebelum Berjalan

Seri I4 — Bahasa Python Tingkat Lanjut

CatatanDi bab ini, Anda akan:
  • Menulis anotasi tipe (type hint) pada variabel dan fungsi
  • Memakai tipe untuk koleksi, pilihan (X | None), fungsi, dan generics
  • Mengenal TypedDict, Literal, dan Protocol
  • Menjalankan mypy sungguhan dan membaca keluarannya
  • Memahami perubahan Python 3.14: anotasi dievaluasi belakangan

Semua skrip bab ini ada di folder kode/bab33/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Bayangkan gudang pakan dengan karung tanpa label. Anda harus membuka tiap karung untuk tahu isinya, dan kadang salah mengambil. Sekarang bayangkan tiap karung berlabel “pelet ikan, 25 kg”. Anda langsung tahu, dan petugas yang memeriksa gudang bisa menandai karung yang salah tempat sebelum pakan dipakai.

Type hint (petunjuk tipe) adalah label itu pada kode: catatan tentang jenis data yang diharapkan sebuah variabel atau fungsi. Python sendiri tidak memaksakannya saat program berjalan, tetapi manusia, editor, dan alat pemeriksa bisa memanfaatkannya untuk menemukan kesalahan sebelum program dijalankan.

%%{init: {"flowchart": {"diagramPadding": 40}}}%%
flowchart LR
  A["Kode dengan<br/>type hint"] --> B["Pemeriksa tipe<br/>(mypy / pyright)"]
  B --> C{"Ada yang<br/>tidak cocok?"}
  C -- "ya" --> D["Daftar kesalahan,<br/>program belum dijalankan"]
  C -- "tidak" --> E["Lanjut jalankan<br/>program"]

Type hint dipakai alat pemeriksa (mypy, pyright) untuk menemukan kesalahan sebelum program berjalan.

Anotasi dasar

01_anotasi_dasar.py
# Bab 33 - Anotasi tipe: catatan untuk manusia dan alat, bukan paksaan
def biomassa_kg(jumlah: int, bobot_g: float) -> float:
    return jumlah * bobot_g / 1000


umur_hari: int = 120
nama_kolam: str = "A"

print(biomassa_kg(800, 540.5))
print(biomassa_kg.__annotations__)

def gandakan(jumlah: int) -> int:
    return jumlah * 2


# Python TIDAK memeriksa anotasi saat program jalan
print(gandakan(21))
print(gandakan("ab"))           # bukan int, tetapi tetap jalan
432.4
{'jumlah': <class 'int'>, 'bobot_g': <class 'float'>, 'return': <class 'float'>}
42
abab
  • jumlah: int artinya “jumlah diharapkan bilangan bulat”. -> float di ujung artinya “fungsi mengembalikan angka desimal”.
  • Variabel juga bisa diberi label: umur_hari: int = 120.
  • Anotasi disimpan di __annotations__ sebagai catatan.
  • Python tidak memeriksanya saat berjalan. gandakan("ab") bukan int, tetapi tetap jalan dan menghasilkan abab (teks dikali dua mengulang teks). Tidak ada galat. Pemeriksaan dilakukan alat terpisah, bukan Python.
CatatanINGAT

Type hint adalah catatan, bukan penjaga pintu. Tanpa alat pemeriksa, salah tipe baru ketahuan saat program berjalan, atau malah tidak ketahuan seperti abab di atas. Untuk penjaga pintu saat berjalan, lihat Pydantic di Bab 34.

Tipe untuk koleksi, pilihan, dan fungsi

02_tipe_koleksi.py
# Bab 33 - Tipe untuk koleksi, pilihan, dan fungsi
from collections.abc import Callable, Iterator


def rata_rata(angka: list[float]) -> float:
    return sum(angka) / len(angka)


def cari_kolam(data: dict[str, float], nama: str) -> float | None:
    """Mengembalikan bobot kolam, atau None bila tidak ada."""
    return data.get(nama)


def titik(x: float, y: float) -> tuple[float, float]:
    return (x, y)


def terapkan(fungsi: Callable[[float], float], nilai: list[float]) -> list[float]:
    return [fungsi(n) for n in nilai]


def hitung_mundur(n: int) -> Iterator[int]:
    while n > 0:
        yield n
        n -= 1


bobot = {"A": 540.5, "B": 498.0}
print(rata_rata([540.5, 498.0, 610.0]))
print(cari_kolam(bobot, "A"), cari_kolam(bobot, "Z"))
print(titik(1.5, 2.0))
print(terapkan(lambda g: g / 1000, [540.5, 498.0]))
print(list(hitung_mundur(3)))
549.5
540.5 None
(1.5, 2.0)
[0.5405, 0.498]
[3, 2, 1]
Penulisan Artinya
list[float] list yang isinya desimal
dict[str, float] dict berkunci teks, bernilai desimal
tuple[float, float] tuple dua desimal
float \| None desimal atau None (artinya “boleh kosong”)
Callable[[float], float] fungsi yang menerima satu desimal dan mengembalikan desimal
Iterator[int] sesuatu yang bisa di-next() dan memberi bilangan bulat

cari_kolam(bobot, "Z") mengembalikan None karena kolam Z tidak ada; itu sebabnya tipenya float | None. Penulisan float | None memakai tanda | (Python 3.10 ke atas). Callable dan Iterator diambil dari collections.abc. Perhatikan: hitung_mundur adalah generator (Bab 30), dan tipe yang pas untuk generator sederhana adalah Iterator[int].

Generics: tipe yang mengikuti isi

Fungsi pertama(daftar) bisa dipakai pada list desimal maupun list teks. Tipe hasilnya harus mengikuti: desimal bila diberi list desimal, teks bila diberi list teks. Itu tugas generics, dengan variabel tipe T.

03_generics.py
# Bab 33 - Generics: tipe yang mengikuti isi
from collections.abc import Sequence


def pertama[T](daftar: Sequence[T]) -> T:
    """Mengembalikan elemen pertama; tipenya sama dengan isi daftar."""
    return daftar[0]


print(pertama([540.5, 498.0]))      # T = float
print(pertama(["A", "B"]))          # T = str


class Gudang[T]:
    """Tempat menyimpan barang bertipe sama."""

    def __init__(self) -> None:
        self.isi: list[T] = []

    def simpan(self, barang: T) -> None:
        self.isi.append(barang)

    def ambil(self) -> T:
        return self.isi.pop()


gudang_bobot = Gudang[float]()
gudang_bobot.simpan(540.5)
print(gudang_bobot.ambil())
540.5
A
540.5

Sintaks def pertama[T](...) dan class Gudang[T] adalah cara penulisan yang diperkenalkan di Python 3.12 (sebelumnya memakai TypeVar dan Generic). Gudang[float]() membuat gudang khusus desimal. Alat pemeriksa lalu akan memprotes bila Anda menyimpan teks ke dalamnya.

TypedDict, Literal, dan Protocol

Data lapangan sering berbentuk dict. TypedDict menyatakan kunci apa saja yang ada dan tipenya. Literal membatasi pilihan ke nilai tertentu. Protocol menyatakan “apa pun yang punya ini” tanpa mewajibkan pewarisan (disebut duck typing: “bila berjalan seperti bebek dan bersuara seperti bebek, ia bebek”).

04_tipe_data_lapangan.py
# Bab 33 - TypedDict, Literal, dan Protocol
from dataclasses import dataclass
from typing import Literal, Protocol, TypedDict

Sektor = Literal["hutan", "kebun", "sawah", "kolam", "kandang"]


class Rekaman(TypedDict):
    sektor: Sektor
    unit: str
    nilai: float


@dataclass
class Petak:
    nama: str
    luas_ha: float


class PunyaNama(Protocol):
    """Tipe 'bebek': apa pun yang punya atribut nama."""
    nama: str


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


r: Rekaman = {"sektor": "kolam", "unit": "A", "nilai": 540.5}
print(r["sektor"], r["nilai"])
print(sapa(Petak("Blok A", 1.5)))
kolam 540.5
Halo, Blok A

Rekaman adalah dict dengan tiga kunci, dan sektor hanya boleh salah satu dari lima nilai. sapa() menerima apa saja yang punya atribut nama: di sini Petak, sebuah dataclass (Bab 16), tanpa mewarisi apa pun. Saat berjalan, semuanya biasa dict dan objek biasa; labelnya hanya untuk pemeriksa.

Menjalankan mypy sungguhan

mypy adalah alat pemeriksa tipe. Skrip di bawah menulis berkas kecil yang sengaja salah, menjalankan mypy atasnya, dan menampilkan keluaran aslinya.

05_jalankan_mypy.py
# Bab 33 - Menjalankan mypy sungguhan pada berkas kecil
import subprocess
import sys
from pathlib import Path

folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
contoh = folder / "contoh_salah.py"
contoh.write_text('''def biomassa_kg(jumlah: int, bobot_g: float) -> float:
    return jumlah * bobot_g / 1000


total: int = biomassa_kg(800, 540.5)
print(biomassa_kg("800", 540.5))
print(biomassa_kg(800))
nama: str = None
''', encoding="utf-8")

hasil = subprocess.run(
    [sys.executable, "-m", "mypy", "--no-color-output", "--no-error-summary",
     "--cache-dir", str(folder / ".mypy_cache"), "contoh_salah.py"],
    cwd=folder, capture_output=True, text=True,
)
print(hasil.stdout.strip())
print("kode keluar mypy:", hasil.returncode)
contoh_salah.py:5: error: Incompatible types in assignment (expression has type "float", variable has type "int")  [assignment]
contoh_salah.py:6: error: Argument 1 to "biomassa_kg" has incompatible type "str"; expected "int"  [arg-type]
contoh_salah.py:7: error: Missing positional argument "bobot_g" in call to "biomassa_kg"  [call-arg]
contoh_salah.py:8: error: Incompatible types in assignment (expression has type "None", variable has type "str")  [assignment]
kode keluar mypy: 1

Mypy menemukan empat kesalahan tanpa menjalankan berkas itu:

  • Baris 5: biomassa_kg mengembalikan float, tetapi disimpan ke variabel yang dijanjikan int.
  • Baris 6: argumen pertama berupa teks "800", padahal diharapkan int.
  • Baris 7: argumen bobot_g kurang.
  • Baris 8: None diberikan ke variabel bertipe str.

Kode dalam kurung siku di ujung ([assignment], [arg-type], [call-arg]) adalah nama kategori galat. Kode keluar mypy 1 berarti “ada masalah”, yang berguna bila mypy dijalankan otomatis (misalnya di pemeriksaan sebelum commit, Bab 19 dan 20).

TipTIPS

Mulailah dari yang kecil. Beri type hint pada fungsi yang paling sering dipakai, jalankan mypy, perbaiki, lalu perluas. Opsi --strict mypy lebih ketat (menuntut hampir semua fungsi diberi tipe); cocok bila proyek sudah rapi.

Pyright, alat pemeriksa lain

Selain mypy, ada pyright, pemeriksa tipe buatan Microsoft. Di halaman proyeknya, pyright digambarkan sebagai pemeriksa tipe statis untuk Python yang tersedia sebagai alat baris perintah dan ekstensi Visual Studio Code. Pyright tidak dijalankan di buku ini; cara memasangnya dan perbedaan perilakunya dengan mypy belum saya verifikasi di komputer penulis, jadi silakan lihat dokumentasinya sendiri bila ingin mencoba. Konsep yang Anda pelajari di sini (anotasi, generics, TypedDict) berlaku sama untuk keduanya.

Python 3.14: anotasi dievaluasi belakangan

Dulu, anotasi seperti x: Kelas langsung dievaluasi saat def dijalankan. Bila Kelas belum didefinisikan, terjadi galat, sehingga orang menulis "Kelas" (dalam tanda kutip) atau from __future__ import annotations. Sejak Python 3.14, anotasi disimpan dan dievaluasi hanya bila diperlukan (sumber: dokumentasi “What’s New in Python 3.14”, bagian anotasi tertunda). Akibatnya acuan ke nama yang belum ada tidak lagi memerlukan tanda kutip. Modul baru annotationlib membaca anotasi dalam tiga format.

06_anotasi_ditunda.py
# Bab 33 - Python 3.14: anotasi dievaluasi belakangan
from annotationlib import Format, get_annotations


def hitung(x: BelumDibuat) -> None:        # nama ini BELUM ada
    pass


print("fungsi berhasil dibuat tanpa galat")
print(get_annotations(hitung, format=Format.STRING))
acuan = get_annotations(hitung, format=Format.FORWARDREF)["x"]
print(type(acuan).__name__, acuan.__forward_arg__)
try:
    get_annotations(hitung, format=Format.VALUE)
except NameError as e:
    print("NameError:", e)


class BelumDibuat:
    pass


print(get_annotations(hitung, format=Format.VALUE))   # sekarang namanya ada
fungsi berhasil dibuat tanpa galat
{'x': 'BelumDibuat', 'return': 'None'}
ForwardRef BelumDibuat
NameError: name 'BelumDibuat' is not defined
{'x': <class '__main__.BelumDibuat'>, 'return': None}
  • hitung(x: BelumDibuat) berhasil dibuat padahal BelumDibuat belum ada. Pada Python lama ini langsung galat NameError.
  • Format STRING menampilkan anotasi sebagai teks, FORWARDREF sebagai acuan (ForwardRef) bagi nama yang belum ada, dan VALUE mengevaluasi nilainya sungguhan.
  • Format VALUE sebelum kelas dibuat menghasilkan NameError. Setelah BelumDibuat didefinisikan, format VALUE berhasil dan mengembalikan kelasnya.

Skrip ini hanya berjalan di Python 3.14 ke atas, karena annotationlib baru ada di sana dan perilaku “fungsi dibuat walau nama belum ada” juga khas 3.14. Pembaca yang masih memakai Python lama sebaiknya tetap menulis acuan maju dalam tanda kutip. Kode from __future__ import annotations tetap dikenali dan mempertahankan perilaku lama.

Proyek Bersambung I4: memberi tipe pada kode proyek

Kita beri type hint pada fungsi pembaca rekaman dari Bab 30, lalu periksa dengan mypy mode ketat. Skrip menulis modul kecil lapangan.py ke _tmp/, menjalankan mypy, menyisipkan satu kesalahan sengaja, menjalankan mypy lagi, lalu menjalankan modulnya.

07_proyek_ketik_kode.py
# Bab 33 - Proyek bersambung I4: memberi tipe pada kode proyek, lalu diperiksa mypy
import subprocess
import sys
from pathlib import Path

folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
modul = folder / "lapangan.py"
modul.write_text('''from collections.abc import Iterable, Iterator
from typing import Literal, TypedDict, cast

Sektor = Literal["hutan", "kebun", "sawah", "kolam", "kandang"]


class Rekaman(TypedDict):
    sektor: Sektor
    unit: str
    nilai: float


def baca(baris: Iterable[str]) -> Iterator[Rekaman]:
    for b in baris:
        sektor, unit, nilai = b.split(",")
        yield {"sektor": cast(Sektor, sektor), "unit": unit, "nilai": float(nilai)}


def rata_rata(rekaman: Iterable[Rekaman]) -> float | None:
    nilai = [r["nilai"] for r in rekaman]
    return sum(nilai) / len(nilai) if nilai else None


print(rata_rata(baca(["kolam,A,540.5", "kolam,B,498.0"])))
print(rata_rata(baca([])))
''', encoding="utf-8")


def mypy(*opsi: str) -> None:
    hasil = subprocess.run(
        [sys.executable, "-m", "mypy", "--no-color-output", "--cache-dir", str(folder / ".mypy_cache"),
         *opsi, "lapangan.py"],
        cwd=folder, capture_output=True, text=True,
    )
    print(hasil.stdout.strip())


print("== mypy ==")
mypy("--strict")
print("== sengaja disisipi satu kesalahan ==")
modul.write_text(modul.read_text(encoding="utf-8") + "print(rata_rata([1.5, 2.5]))" + chr(10), encoding="utf-8")
mypy("--strict")
print("== menjalankan modul ==")
jalan = subprocess.run([sys.executable, "lapangan.py"], cwd=folder, capture_output=True, text=True)
print(jalan.stdout.strip())
print("galat saat jalan:", jalan.stderr.strip().splitlines()[-1])
== mypy ==
Success: no issues found in 1 source file
== sengaja disisipi satu kesalahan ==
lapangan.py:26: error: List item 0 has incompatible type "float"; expected "Rekaman"  [list-item]
lapangan.py:26: error: List item 1 has incompatible type "float"; expected "Rekaman"  [list-item]
Found 2 errors in 1 file (checked 1 source file)
== menjalankan modul ==
519.25
None
galat saat jalan: TypeError: 'float' object is not subscriptable

Telusuri:

  • Mypy --strict menyatakan modul bersih: Success: no issues found in 1 source file. cast(Sektor, sektor) memberi tahu mypy bahwa kita yakin teks itu salah satu nilai Sektor (mypy tidak bisa mengetahuinya dari teks bebas). Itu janji kita, bukan pemeriksaan; pemeriksaan sungguhan datang di Bab 34.
  • Setelah baris print(rata_rata([1.5, 2.5])) disisipkan (seharusnya memberi Rekaman, bukan angka), mypy melaporkan dua kesalahan, satu untuk tiap elemen list. Program belum dijalankan saat itu.
  • Bila modul tetap dijalankan, yang tercetak adalah 519.25 dan None dari dua baris pertama, lalu program jatuh di baris sisipan dengan TypeError: 'float' object is not subscriptable. Mypy menangkap kesalahan yang sama tanpa perlu menunggu program jatuh.

Latihan

  1. Beri type hint pada fungsi rata_rata_bobot(bobot) yang menerima list[float] dan mengembalikan float.
  2. Tulis fungsi cari_pohon(daftar: list[str], nama: str) -> int | None yang mengembalikan posisi nama dalam daftar, atau None bila tidak ada.
  3. Ubah 05_jalankan_mypy.py: perbaiki empat kesalahan pada berkas contoh, jalankan lagi, dan lihat pesan Success.
  4. Tantangan: buat TypedDict bernama Ternak (kunci kandang: str, jenis: Literal["sapi", "kambing", "ayam"], bobot_kg: float), lalu fungsi bobot_total(data: list[Ternak]) -> float. Periksa dengan mypy, termasuk satu data yang jenis-nya salah ketik.

Ringkasan Bab

  • Type hint adalah label tipe pada variabel dan fungsi; Python tidak memeriksanya saat berjalan.
  • Alat seperti mypy (dan pyright) memeriksa tipe sebelum program berjalan dan melaporkan ketidakcocokan.
  • Tipe umum: list[float], dict[str, float], float | None, Callable, Iterator; generics ([T]) membuat tipe mengikuti isi.
  • TypedDict, Literal, dan Protocol menyatakan bentuk dict, pilihan terbatas, dan “apa pun yang punya ini”.
  • Python 3.14 menunda evaluasi anotasi; annotationlib membaca dalam format VALUE, FORWARDREF, dan STRING.
  • Proyek I4: kode pembaca rekaman diberi tipe dan lulus mypy --strict; kesalahan sisipan terlihat sebelum dijalankan.

Selanjutnya, di Bab 34: validasi data dan konfigurasi dengan Pydantic, penjaga pintu yang bekerja saat program berjalan.